diff --git a/.eslintrc b/.eslintrc index 44a8d5ac..786b7e5a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -39,6 +39,7 @@ "message": "Use `globalThis` instead" } ], + "prefer-rest-params": 0, "require-yield": 0, "eqeqeq": ["error", "smart"], "spaced-comment": [ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..d450e979 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,307 @@ +workflow: + rules: + # Disable merge request pipelines + - if: $CI_MERGE_REQUEST_ID + when: never + - when: always + +variables: + GIT_SUBMODULE_STRATEGY: recursive + GH_PROJECT_PATH: "MatrixAI/${CI_PROJECT_NAME}" + GH_PROJECT_URL: "https://${GITHUB_TOKEN}@github.com/${GH_PROJECT_PATH}.git" + # Cache .npm + npm_config_cache: "${CI_PROJECT_DIR}/tmp/npm" + # Prefer offline node module installation + npm_config_prefer_offline: "true" + # Homebrew cache only used by macos runner + HOMEBREW_CACHE: "${CI_PROJECT_DIR}/tmp/Homebrew" + +default: + interruptible: true + before_script: + # Replace this in windows runners that use powershell + # with `mkdir -Force "$CI_PROJECT_DIR/tmp"` + - mkdir -p "$CI_PROJECT_DIR/tmp" + +# Cached directories shared between jobs & pipelines per-branch per-runner +cache: + key: $CI_COMMIT_REF_SLUG + # Preserve cache even if job fails + when: 'always' + paths: + - ./tmp/npm/ + # Homebrew cache is only used by the macos runner + - ./tmp/Homebrew + # Chocolatey cache is only used by the windows runner + - ./tmp/chocolatey/ + # `jest` cache is configured in jest.config.js + - ./tmp/jest/ + +stages: + - check # Linting, unit tests + - build # Cross-platform library compilation, unit tests + - integration # Cross-platform application bundling, integration tests, and pre-release + - release # Cross-platform distribution and deployment + +image: registry.gitlab.com/matrixai/engineering/maintenance/gitlab-runner + +check:lint: + stage: check + needs: [] + script: + - > + nix-shell --arg ci true --run $' + npm run lint; + npm run lint-shell; + ' + rules: + # Runs on feature and staging commits and ignores version commits + - if: $CI_COMMIT_BRANCH =~ /^(?:feature.*|staging)$/ && $CI_COMMIT_TITLE !~ /^[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + # Runs on tag pipeline where the tag is a prerelease or release version + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + # Manually run on commits other than master and ignore version commits + - if: $CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != 'master' && $CI_COMMIT_TITLE !~ /^[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + when: manual + +check:test: + stage: check + needs: [] + script: + - > + nix-shell --arg ci true --run $' + npm test -- --ci --coverage; + ' + artifacts: + when: always + reports: + junit: + - ./tmp/junit/junit.xml + coverage_report: + coverage_format: cobertura + path: ./tmp/coverage/cobertura-coverage.xml + coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/' + rules: + # Runs on feature commits and ignores version commits + - if: $CI_COMMIT_BRANCH =~ /^feature.*$/ && $CI_COMMIT_TITLE !~ /^[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + # Manually run on commits other than master and staging and ignore version commits + - if: $CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH !~ /^(?:master|staging)$/ && $CI_COMMIT_TITLE !~ /^[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + when: manual + +build:merge: + stage: build + needs: [] + allow_failure: true + script: + # Required for `gh pr create` + - git remote add upstream "$GH_PROJECT_URL" + - > + nix-shell --arg ci true --run $' + gh pr create \ + --head staging \ + --base master \ + --title "ci: merge staging to master" \ + --body "This is an automatic PR generated by the pipeline CI/CD. This will be automatically fast-forward merged if successful." \ + --assignee "@me" \ + --no-maintainer-edit \ + --repo "$GH_PROJECT_PATH" || true; + printf "Pipeline Attempt on ${CI_PIPELINE_ID} for ${CI_COMMIT_SHA}\n\n${CI_PIPELINE_URL}" \ + | gh pr comment staging \ + --body-file - \ + --repo "$GH_PROJECT_PATH"; + ' + rules: + # Runs on staging commits and ignores version commits + - if: $CI_COMMIT_BRANCH == 'staging' && $CI_COMMIT_TITLE !~ /^[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + # Runs on tag pipeline where the tag is a prerelease or release version + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + +build:dist: + stage: build + needs: [] + script: + - > + nix-shell --arg ci true --run $' + npm run build --verbose; + ' + artifacts: + when: always + paths: + - ./dist + rules: + # Runs on staging commits and ignores version commits + - if: $CI_COMMIT_BRANCH == 'staging' && $CI_COMMIT_TITLE !~ /^[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + # Runs on tag pipeline where the tag is a prerelease or release version + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + +build:linux: + stage: build + needs: [] + script: + - > + nix-shell --arg ci true --run $' + npm test -- --ci --coverage; + npm run bench; + ' + artifacts: + when: always + reports: + junit: + - ./tmp/junit/junit.xml + coverage_report: + coverage_format: cobertura + path: ./tmp/coverage/cobertura-coverage.xml + metrics: ./benches/results/metrics.txt + coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/' + rules: + # Runs on staging commits and ignores version commits + - if: $CI_COMMIT_BRANCH == 'staging' && $CI_COMMIT_TITLE !~ /^[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + # Runs on tag pipeline where the tag is a prerelease or release version + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + +build:windows: + stage: build + needs: [] + tags: + - windows + before_script: + - mkdir -Force "$CI_PROJECT_DIR/tmp" + script: + - .\scripts\choco-install.ps1 + - refreshenv + - npm install --ignore-scripts + - $env:Path = "$(npm root)\.bin;" + $env:Path + - npm test -- --ci --coverage + - npm run bench + artifacts: + when: always + reports: + junit: + - ./tmp/junit/junit.xml + coverage_report: + coverage_format: cobertura + path: ./tmp/coverage/cobertura-coverage.xml + metrics: ./benches/results/metrics.txt + coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/' + rules: + # Runs on staging commits and ignores version commits + - if: $CI_COMMIT_BRANCH == 'staging' && $CI_COMMIT_TITLE !~ /^[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + # Runs on tag pipeline where the tag is a prerelease or release version + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + +build:macos: + stage: build + needs: [] + tags: + - saas-macos-medium-m1 + image: macos-12-xcode-14 + script: + - eval "$(brew shellenv)" + - ./scripts/brew-install.sh + - hash -r + - npm install --ignore-scripts + - export PATH="$(npm root)/.bin:$PATH" + - npm test -- --ci --coverage + - npm run bench + artifacts: + when: always + reports: + junit: + - ./tmp/junit/junit.xml + coverage_report: + coverage_format: cobertura + path: ./tmp/coverage/cobertura-coverage.xml + metrics: ./benches/results/metrics.txt + coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/' + rules: + # Runs on staging commits and ignores version commits + - if: $CI_COMMIT_BRANCH == 'staging' && $CI_COMMIT_TITLE !~ /^[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + # Runs on tag pipeline where the tag is a prerelease or release version + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + +build:prerelease: + stage: build + needs: + - build:dist + - build:linux + - build:windows + - build:macos + # Don't interrupt publishing job + interruptible: false + script: + - echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ./.npmrc + - echo 'Publishing library prerelease' + - > + nix-shell --arg ci true --run $' + npm publish --tag prerelease --access public; + ' + after_script: + - rm -f ./.npmrc + rules: + # Only runs on tag pipeline where the tag is a prerelease version + # This requires dependencies to also run on tag pipeline + # However version tag comes with a version commit + # Dependencies must not run on the version commit + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+-.*[0-9]+$/ + +integration:merge: + stage: integration + needs: + - build:merge + - job: build:linux + optional: true + - job: build:windows + optional: true + - job: build:macos + optional: true + # Requires mutual exclusion + resource_group: integration:merge + allow_failure: true + variables: + # Ensure that CI/CD is fetching all commits + # this is necessary to checkout origin/master + # and to also merge origin/staging + GIT_DEPTH: 0 + script: + - > + nix-shell --arg ci true --run $' + printf "Pipeline Succeeded on ${CI_PIPELINE_ID} for ${CI_COMMIT_SHA}\n\n${CI_PIPELINE_URL}" \ + | gh pr comment staging \ + --body-file - \ + --repo "$GH_PROJECT_PATH"; + ' + - git remote add upstream "$GH_PROJECT_URL" + - git checkout origin/master + # Merge up to the current commit (not the latest commit) + - git merge --ff-only "$CI_COMMIT_SHA" + - git push upstream HEAD:master + rules: + # Runs on staging commits and ignores version commits + - if: $CI_COMMIT_BRANCH == 'staging' && $CI_COMMIT_TITLE !~ /^[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + # Runs on tag pipeline where the tag is a prerelease or release version + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+(?:-.*[0-9]+)?$/ + +release:distribution: + stage: release + needs: + - build:dist + - build:linux + - build:windows + - build:macos + - integration:merge + # Don't interrupt publishing job + interruptible: false + script: + - echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ./.npmrc + - echo 'Publishing library' + - > + nix-shell --arg ci true --run $' + npm publish --access public; + ' + after_script: + - rm -f ./.npmrc + rules: + # Only runs on tag pipeline where the tag is a release version + # This requires dependencies to also run on tag pipeline + # However version tag comes with a version commit + # Dependencies must not run on the version commit + - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..72446f43 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/README.md b/README.md new file mode 100644 index 00000000..bbfeda16 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# js-ws + +staging:[![pipeline status](https://gitlab.com/MatrixAI/open-source/js-ws/badges/staging/pipeline.svg)](https://gitlab.com/MatrixAI/open-source/js-ws/commits/staging) +master:[![pipeline status](https://gitlab.com/MatrixAI/open-source/js-ws/badges/master/pipeline.svg)](https://gitlab.com/MatrixAI/open-source/js-ws/commits/master) + +WebSocket library for TypeScript/JavaScript applications. + +This is built on top of the [ws](https://github.com/websockets/ws) library, providing a multiplexed WebStreams API on top of WebSocket. + +## Installation + +```sh +npm install --save @matrixai/ws +``` + +## Development + +Run `nix-shell`, and once you're inside, you can use: + +```sh +# install (or reinstall packages from package.json) +npm install +# build the dist +npm run build +# run the repl (this allows you to import from ./src) +npm run ts-node +# run the tests +npm run test +# lint the source code +npm run lint +# automatically fix the source +npm run lintfix +``` + +### Benchmarks + +View benchmarks here: https://github.com/MatrixAI/js-ws/blob/master/benches/results with https://raw.githack.com/ + +### Docs Generation + +```sh +npm run docs +``` + +See the docs at: https://matrixai.github.io/js-ws/ + +### Publishing + +Publishing is handled automatically by the staging pipeline. + +Prerelease: + +```sh +# npm login +npm version prepatch --preid alpha # premajor/preminor/prepatch +git push --follow-tags +``` + +Release: + +```sh +# npm login +npm version patch # major/minor/patch +git push --follow-tags +``` + +Manually: + +```sh +# npm login +npm version patch # major/minor/patch +npm run build +npm publish --access public +git push +git push --tags +``` diff --git a/benches/index.ts b/benches/index.ts new file mode 100644 index 00000000..dd2d64dd --- /dev/null +++ b/benches/index.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env ts-node + +import type { Summary } from 'benny/lib/internal/common-types'; +import fs from 'fs'; +import path from 'path'; +import si from 'systeminformation'; +import { fsWalk, resultsPath, suitesPath } from './utils'; + +async function main(): Promise { + await fs.promises.mkdir(path.join(__dirname, 'results'), { recursive: true }); + // Running all suites + for await (const suitePath of fsWalk(suitesPath)) { + // Skip over non-ts and non-js files + const ext = path.extname(suitePath); + if (ext !== '.ts' && ext !== '.js') { + continue; + } + const suite: () => Promise = (await import(suitePath)).default; + await suite(); + } + // Concatenating metrics + const metricsPath = path.join(resultsPath, 'metrics.txt'); + let concatenating = false; + for await (const metricPath of fsWalk(resultsPath)) { + // Skip over non-metrics files + if (!metricPath.endsWith('_metrics.txt')) { + continue; + } + const metricData = await fs.promises.readFile(metricPath); + if (concatenating) { + await fs.promises.appendFile(metricsPath, '\n'); + } + await fs.promises.appendFile(metricsPath, metricData); + concatenating = true; + } + const systemData = await si.get({ + cpu: '*', + osInfo: 'platform, distro, release, kernel, arch', + system: 'model, manufacturer', + }); + await fs.promises.writeFile( + path.join(__dirname, 'results', 'system.json'), + JSON.stringify(systemData, null, 2), + ); +} + +if (require.main === module) { + void main(); +} + +export default main; diff --git a/benches/results/baseline_tcp/baseline_tcp_1KiB.chart.html b/benches/results/baseline_tcp/baseline_tcp_1KiB.chart.html new file mode 100644 index 00000000..5b360644 --- /dev/null +++ b/benches/results/baseline_tcp/baseline_tcp_1KiB.chart.html @@ -0,0 +1,116 @@ + + + + + + + + baseline_tcp.baseline_tcp_1KiB + + + +
+ +
+ + + \ No newline at end of file diff --git a/benches/results/baseline_tcp/baseline_tcp_1KiB.json b/benches/results/baseline_tcp/baseline_tcp_1KiB.json new file mode 100644 index 00000000..2c5d0018 --- /dev/null +++ b/benches/results/baseline_tcp/baseline_tcp_1KiB.json @@ -0,0 +1,117 @@ +{ + "name": "baseline_tcp.baseline_tcp_1KiB", + "date": "2023-09-18T03:21:44.180Z", + "version": "1.0.0", + "results": [ + { + "name": "send 1KiB of data over tcp", + "ops": 405101, + "margin": 2.96, + "options": { + "delay": 0.005, + "initCount": 1, + "minTime": 0.05, + "maxTime": 5, + "minSamples": 5 + }, + "samples": 73, + "promise": true, + "details": { + "min": 0.000002082702214499313, + "max": 0.000003828425337808762, + "mean": 0.000002468520334246925, + "median": 0.000002396928836983755, + "standardDeviation": 3.1881353382451324e-7, + "marginOfError": 7.313603141132788e-8, + "relativeMarginOfError": 2.962747780388027, + "standardErrorOfMean": 3.731430174047341e-8, + "sampleVariance": 1.0164206934967404e-13, + "sampleResults": [ + 0.000002082702214499313, + 0.0000021157753172229855, + 0.0000021207977046795443, + 0.0000021341038955790833, + 0.0000021450218217085592, + 0.000002157392503657802, + 0.0000021700462174025306, + 0.0000021709286083139687, + 0.000002189253335054652, + 0.000002196447062151459, + 0.000002198187881917549, + 0.0000021989740941561237, + 0.000002206806635415825, + 0.000002209668775281866, + 0.0000022111516051295293, + 0.0000022151254410878734, + 0.000002225657601228481, + 0.0000022301635252603495, + 0.0000022331767558393276, + 0.000002233815345554695, + 0.0000022364440313586033, + 0.0000022500212559605593, + 0.000002255188613477924, + 0.000002257421361028045, + 0.0000022659864446165764, + 0.0000022700626129615285, + 0.0000022782678371632673, + 0.000002327111937282793, + 0.0000023290124868665645, + 0.0000023349306962907875, + 0.000002350769578921846, + 0.0000023572262712652227, + 0.000002367353211862043, + 0.0000023712139537045645, + 0.0000023893310838115253, + 0.000002393997861857395, + 0.000002396928836983755, + 0.0000023976179584579325, + 0.00000240698533096258, + 0.0000024105790834882407, + 0.0000024211996686333145, + 0.0000024326061989816536, + 0.0000024603796977289258, + 0.0000024665429160268326, + 0.0000024745125225923057, + 0.00000248192279886393, + 0.000002516833978827782, + 0.0000025184406368706054, + 0.0000025278171421643903, + 0.0000025361103612705084, + 0.000002552573345187101, + 0.000002583986543279722, + 0.000002587261294754708, + 0.0000025956051887173682, + 0.000002602435545138608, + 0.0000026110299038228402, + 0.000002612131379636802, + 0.0000026124045098545486, + 0.0000026616409329546433, + 0.000002673025983997414, + 0.00000278994319648851, + 0.0000028078796573183544, + 0.0000028139181082709352, + 0.0000028216522936569415, + 0.0000028260340390739308, + 0.0000028289450899388934, + 0.000002839286026024408, + 0.0000028700633014889405, + 0.0000029344789138480075, + 0.0000029756092176607283, + 0.00000302764592477838, + 0.0000036179959549014547, + 0.000003828425337808762 + ] + }, + "completed": true, + "percentSlower": 0 + } + ], + "fastest": { + "name": "send 1KiB of data over tcp", + "index": 0 + }, + "slowest": { + "name": "send 1KiB of data over tcp", + "index": 0 + } +} \ No newline at end of file diff --git a/benches/results/baseline_tcp/baseline_tcp_1KiB_metrics.txt b/benches/results/baseline_tcp/baseline_tcp_1KiB_metrics.txt new file mode 100644 index 00000000..605343ec --- /dev/null +++ b/benches/results/baseline_tcp/baseline_tcp_1KiB_metrics.txt @@ -0,0 +1,8 @@ +# TYPE baseline_tcp.baseline_tcp_1KiB_ops gauge +baseline_tcp.baseline_tcp_1KiB_ops{name="send 1KiB of data over tcp"} 405101 + +# TYPE baseline_tcp.baseline_tcp_1KiB_margin gauge +baseline_tcp.baseline_tcp_1KiB_margin{name="send 1KiB of data over tcp"} 2.96 + +# TYPE baseline_tcp.baseline_tcp_1KiB_samples counter +baseline_tcp.baseline_tcp_1KiB_samples{name="send 1KiB of data over tcp"} 73 diff --git a/benches/results/baseline_websocket/baseline_websocket_1KiB.chart.html b/benches/results/baseline_websocket/baseline_websocket_1KiB.chart.html new file mode 100644 index 00000000..01201f89 --- /dev/null +++ b/benches/results/baseline_websocket/baseline_websocket_1KiB.chart.html @@ -0,0 +1,116 @@ + + + + + + + + baseline_websocket.baseline_websocket_1KiB + + + +
+ +
+ + + \ No newline at end of file diff --git a/benches/results/baseline_websocket/baseline_websocket_1KiB.json b/benches/results/baseline_websocket/baseline_websocket_1KiB.json new file mode 100644 index 00000000..ea279050 --- /dev/null +++ b/benches/results/baseline_websocket/baseline_websocket_1KiB.json @@ -0,0 +1,116 @@ +{ + "name": "baseline_websocket.baseline_websocket_1KiB", + "date": "2023-09-18T03:21:51.404Z", + "version": "1.0.0", + "results": [ + { + "name": "send 1KiB of data over ws", + "ops": 24208, + "margin": 7.18, + "options": { + "delay": 0.005, + "initCount": 1, + "minTime": 0.05, + "maxTime": 5, + "minSamples": 5 + }, + "samples": 72, + "promise": true, + "details": { + "min": 0.000032055431498079384, + "max": 0.00011758035073891626, + "mean": 0.00004130803008975624, + "median": 0.000037874235357377396, + "standardDeviation": 0.000012837657952578756, + "marginOfError": 0.000002965347697655137, + "relativeMarginOfError": 7.1786228760167825, + "standardErrorOfMean": 0.0000015129324988036415, + "sampleVariance": 1.648054617074086e-10, + "sampleResults": [ + 0.000032055431498079384, + 0.00003213666325224072, + 0.00003233340268886044, + 0.000032373719590268885, + 0.000032405685019206146, + 0.000032447777208706785, + 0.00003262061395646607, + 0.0000326400966709347, + 0.000033267879641485274, + 0.000033432182458386684, + 0.00003350231177976953, + 0.000033518234314980796, + 0.00003409001472471191, + 0.000034214969270166456, + 0.000034360416773367475, + 0.00003440045582586427, + 0.000034498315620998725, + 0.00003458674967989757, + 0.00003466365172855314, + 0.00003571075864276568, + 0.00003581435851472471, + 0.00003582744878361075, + 0.00003587879513444302, + 0.00003592684122919334, + 0.00003605567925736236, + 0.00003605783674775928, + 0.00003614819217330538, + 0.000036275582586427655, + 0.00003641451074498567, + 0.00003647210744985673, + 0.00003721697631241997, + 0.00003740844814340589, + 0.00003744105249679898, + 0.00003752807746478874, + 0.00003760439180537772, + 0.00003765369206145967, + 0.00003809477865329513, + 0.000038161840670859534, + 0.00003818660027952481, + 0.000038365491037131884, + 0.00003839853724928366, + 0.0000384727676056338, + 0.00003885788732394366, + 0.00003902328080229226, + 0.00003941567541613316, + 0.000039738814980793854, + 0.00004003255633802817, + 0.000040283798335467355, + 0.00004050051203852327, + 0.000040802196275071635, + 0.000040980042959427205, + 0.000041256695902688854, + 0.000041315372391653294, + 0.00004186204865556979, + 0.000042594030089628684, + 0.0000429290204865557, + 0.00004378450937245314, + 0.00004422870678617158, + 0.000045056834827144686, + 0.000046403254161331625, + 0.00004854271574903969, + 0.000048558367477592835, + 0.00004904959090909091, + 0.000049539873891625616, + 0.000049588836107554415, + 0.00005055488177339902, + 0.00005667558226600985, + 0.00005708498555377207, + 0.00006038951728553137, + 0.00006177685615763547, + 0.00009110803466204507, + 0.00011758035073891626 + ] + }, + "completed": true, + "percentSlower": 0 + } + ], + "fastest": { + "name": "send 1KiB of data over ws", + "index": 0 + }, + "slowest": { + "name": "send 1KiB of data over ws", + "index": 0 + } +} \ No newline at end of file diff --git a/benches/results/baseline_websocket/baseline_websocket_1KiB_metrics.txt b/benches/results/baseline_websocket/baseline_websocket_1KiB_metrics.txt new file mode 100644 index 00000000..3558a10c --- /dev/null +++ b/benches/results/baseline_websocket/baseline_websocket_1KiB_metrics.txt @@ -0,0 +1,8 @@ +# TYPE baseline_websocket.baseline_websocket_1KiB_ops gauge +baseline_websocket.baseline_websocket_1KiB_ops{name="send 1KiB of data over ws"} 24208 + +# TYPE baseline_websocket.baseline_websocket_1KiB_margin gauge +baseline_websocket.baseline_websocket_1KiB_margin{name="send 1KiB of data over ws"} 7.18 + +# TYPE baseline_websocket.baseline_websocket_1KiB_samples counter +baseline_websocket.baseline_websocket_1KiB_samples{name="send 1KiB of data over ws"} 72 diff --git a/benches/results/connection/connection_1KiB.chart.html b/benches/results/connection/connection_1KiB.chart.html new file mode 100644 index 00000000..40ed61ea --- /dev/null +++ b/benches/results/connection/connection_1KiB.chart.html @@ -0,0 +1,116 @@ + + + + + + + + connection.connection_1KiB + + + +
+ +
+ + + \ No newline at end of file diff --git a/benches/results/connection/connection_1KiB.json b/benches/results/connection/connection_1KiB.json new file mode 100644 index 00000000..978ed873 --- /dev/null +++ b/benches/results/connection/connection_1KiB.json @@ -0,0 +1,122 @@ +{ + "name": "connection.connection_1KiB", + "date": "2023-09-18T03:21:57.896Z", + "version": "1.0.0", + "results": [ + { + "name": "send 1KiB of data over connection", + "ops": 25311, + "margin": 2.64, + "options": { + "delay": 0.005, + "initCount": 1, + "minTime": 0.05, + "maxTime": 5, + "minSamples": 5 + }, + "samples": 78, + "promise": true, + "details": { + "min": 0.000033981254473161034, + "max": 0.00005433586613651425, + "mean": 0.000039507795798468064, + "median": 0.00003778887243207423, + "standardDeviation": 0.000004704777576385195, + "marginOfError": 0.000001044113873697888, + "relativeMarginOfError": 2.642804673345948, + "standardErrorOfMean": 5.327111600499428e-7, + "sampleVariance": 2.2134932043256954e-11, + "sampleResults": [ + 0.000033981254473161034, + 0.00003405409874088801, + 0.000034167324055666, + 0.000034477051689860836, + 0.00003492422133863486, + 0.00003502598811881188, + 0.000035066726308813785, + 0.00003513026308813784, + 0.000035356511597084166, + 0.000035376571901921804, + 0.00003549566998011928, + 0.000035508379058979456, + 0.00003551259045725646, + 0.00003553202717031146, + 0.00003561732736911862, + 0.00003578438237243207, + 0.00003587158117958913, + 0.0000358933492379059, + 0.000035894029158383037, + 0.00003594361895294898, + 0.000036180942345924454, + 0.00003627119483101392, + 0.00003630108151093439, + 0.00003635795029821074, + 0.00003650762690523526, + 0.00003669384824387011, + 0.00003686738767395626, + 0.000037021181577203445, + 0.00003718409343936381, + 0.00003721873426110007, + 0.00003726353081510934, + 0.000037341098740888005, + 0.00003736451491053678, + 0.000037382281643472496, + 0.00003740861630218688, + 0.00003741051225977469, + 0.00003747669781312127, + 0.00003750344201457919, + 0.0000375044731610338, + 0.00003807327170311465, + 0.00003819468853545395, + 0.000038287551358515574, + 0.00003840023326706428, + 0.00003842720079522863, + 0.00003856653346587144, + 0.000038596649436713054, + 0.000038808232604373756, + 0.00003911953346587144, + 0.000039253386348575214, + 0.00003925710801855534, + 0.00003946978727634195, + 0.000039571257786613654, + 0.000039597159045725646, + 0.000039849886679920477, + 0.000040139479125248505, + 0.00004075840888005302, + 0.00004130527634194831, + 0.00004141947514910536, + 0.00004156738502319417, + 0.000041591085487077536, + 0.00004192232803180914, + 0.00004261862955599735, + 0.00004272708946322067, + 0.00004314200927766733, + 0.00004465600861497681, + 0.00004580904042412194, + 0.00004612287408880053, + 0.000046233546719681906, + 0.00004626157852882704, + 0.000046652477137176936, + 0.00004666206693174288, + 0.00004707903711066932, + 0.000047545932405566594, + 0.00004797660834990059, + 0.00004875877137176938, + 0.00005129806626905235, + 0.00005368037707090789, + 0.00005433586613651425 + ] + }, + "completed": true, + "percentSlower": 0 + } + ], + "fastest": { + "name": "send 1KiB of data over connection", + "index": 0 + }, + "slowest": { + "name": "send 1KiB of data over connection", + "index": 0 + } +} \ No newline at end of file diff --git a/benches/results/connection/connection_1KiB_metrics.txt b/benches/results/connection/connection_1KiB_metrics.txt new file mode 100644 index 00000000..39b573be --- /dev/null +++ b/benches/results/connection/connection_1KiB_metrics.txt @@ -0,0 +1,8 @@ +# TYPE connection.connection_1KiB_ops gauge +connection.connection_1KiB_ops{name="send 1KiB of data over connection"} 25311 + +# TYPE connection.connection_1KiB_margin gauge +connection.connection_1KiB_margin{name="send 1KiB of data over connection"} 2.64 + +# TYPE connection.connection_1KiB_samples counter +connection.connection_1KiB_samples{name="send 1KiB of data over connection"} 78 diff --git a/benches/results/metrics.txt b/benches/results/metrics.txt new file mode 100644 index 00000000..9d2ca756 --- /dev/null +++ b/benches/results/metrics.txt @@ -0,0 +1,35 @@ +# TYPE baseline_tcp.baseline_tcp_1KiB_ops gauge +baseline_tcp.baseline_tcp_1KiB_ops{name="send 1KiB of data over tcp"} 405101 + +# TYPE baseline_tcp.baseline_tcp_1KiB_margin gauge +baseline_tcp.baseline_tcp_1KiB_margin{name="send 1KiB of data over tcp"} 2.96 + +# TYPE baseline_tcp.baseline_tcp_1KiB_samples counter +baseline_tcp.baseline_tcp_1KiB_samples{name="send 1KiB of data over tcp"} 73 + +# TYPE baseline_websocket.baseline_websocket_1KiB_ops gauge +baseline_websocket.baseline_websocket_1KiB_ops{name="send 1KiB of data over ws"} 24208 + +# TYPE baseline_websocket.baseline_websocket_1KiB_margin gauge +baseline_websocket.baseline_websocket_1KiB_margin{name="send 1KiB of data over ws"} 7.18 + +# TYPE baseline_websocket.baseline_websocket_1KiB_samples counter +baseline_websocket.baseline_websocket_1KiB_samples{name="send 1KiB of data over ws"} 72 + +# TYPE connection.connection_1KiB_ops gauge +connection.connection_1KiB_ops{name="send 1KiB of data over connection"} 25311 + +# TYPE connection.connection_1KiB_margin gauge +connection.connection_1KiB_margin{name="send 1KiB of data over connection"} 2.64 + +# TYPE connection.connection_1KiB_samples counter +connection.connection_1KiB_samples{name="send 1KiB of data over connection"} 78 + +# TYPE stream.stream_1KiB_ops gauge +stream.stream_1KiB_ops{name="send 1KiB of data over stream"} 10756 + +# TYPE stream.stream_1KiB_margin gauge +stream.stream_1KiB_margin{name="send 1KiB of data over stream"} 4.55 + +# TYPE stream.stream_1KiB_samples counter +stream.stream_1KiB_samples{name="send 1KiB of data over stream"} 47 diff --git a/benches/results/stream/stream_1KiB.chart.html b/benches/results/stream/stream_1KiB.chart.html new file mode 100644 index 00000000..5707d27f --- /dev/null +++ b/benches/results/stream/stream_1KiB.chart.html @@ -0,0 +1,116 @@ + + + + + + + + stream.stream_1KiB + + + +
+ +
+ + + \ No newline at end of file diff --git a/benches/results/stream/stream_1KiB.json b/benches/results/stream/stream_1KiB.json new file mode 100644 index 00000000..178b0b43 --- /dev/null +++ b/benches/results/stream/stream_1KiB.json @@ -0,0 +1,91 @@ +{ + "name": "stream.stream_1KiB", + "date": "2023-09-18T03:22:04.384Z", + "version": "1.0.0", + "results": [ + { + "name": "send 1KiB of data over stream", + "ops": 10756, + "margin": 4.55, + "options": { + "delay": 0.005, + "initCount": 1, + "minTime": 0.05, + "maxTime": 5, + "minSamples": 5 + }, + "samples": 47, + "promise": true, + "details": { + "min": 0.00006986942347826086, + "max": 0.0001358793104347826, + "mean": 0.00009297446869565219, + "median": 0.00009345982869565217, + "standardDeviation": 0.00001478969245452812, + "marginOfError": 0.00000422830479370699, + "relativeMarginOfError": 4.547812806059865, + "standardErrorOfMean": 0.0000021572983641362195, + "sampleVariance": 2.18735002899526e-10, + "sampleResults": [ + 0.00006986942347826086, + 0.00007081246086956522, + 0.00007284170260869566, + 0.00007355179652173912, + 0.00007472226173913043, + 0.00007556405826086957, + 0.00007576699304347826, + 0.00007622311478260869, + 0.0000767906956521739, + 0.00007762487913043478, + 0.00007820606434782609, + 0.00008043893391304348, + 0.00008080719565217392, + 0.00008275557304347826, + 0.0000831222852173913, + 0.00008490704086956521, + 0.00008549686869565218, + 0.00008550366782608695, + 0.00008621142608695652, + 0.00008724585565217392, + 0.00008913530086956522, + 0.0000897419295652174, + 0.00009179414260869565, + 0.00009345982869565217, + 0.00009539451913043478, + 0.00009604621304347827, + 0.00009618041739130434, + 0.00009698898260869565, + 0.00009776252956521739, + 0.00010001894086956522, + 0.00010180377913043478, + 0.00010233602434782609, + 0.00010254488956521739, + 0.00010261674086956521, + 0.00010349913913043479, + 0.00010362847391304347, + 0.00010385673217391304, + 0.00010491441217391305, + 0.00010544541391304348, + 0.00010621787826086956, + 0.00010638576434782609, + 0.00010685981391304348, + 0.00010843221913043478, + 0.00011121257304347825, + 0.00011438107739130434, + 0.00012480068521739132, + 0.0001358793104347826 + ] + }, + "completed": true, + "percentSlower": 0 + } + ], + "fastest": { + "name": "send 1KiB of data over stream", + "index": 0 + }, + "slowest": { + "name": "send 1KiB of data over stream", + "index": 0 + } +} \ No newline at end of file diff --git a/benches/results/stream/stream_1KiB_metrics.txt b/benches/results/stream/stream_1KiB_metrics.txt new file mode 100644 index 00000000..1b0565ad --- /dev/null +++ b/benches/results/stream/stream_1KiB_metrics.txt @@ -0,0 +1,8 @@ +# TYPE stream.stream_1KiB_ops gauge +stream.stream_1KiB_ops{name="send 1KiB of data over stream"} 10756 + +# TYPE stream.stream_1KiB_margin gauge +stream.stream_1KiB_margin{name="send 1KiB of data over stream"} 4.55 + +# TYPE stream.stream_1KiB_samples counter +stream.stream_1KiB_samples{name="send 1KiB of data over stream"} 47 diff --git a/benches/results/system.json b/benches/results/system.json new file mode 100644 index 00000000..853f4aff --- /dev/null +++ b/benches/results/system.json @@ -0,0 +1,41 @@ +{ + "cpu": { + "manufacturer": "Intel", + "brand": "Gen Intel® Core™ i5-11320H", + "vendor": "Intel", + "family": "6", + "model": "140", + "stepping": "2", + "revision": "", + "voltage": "", + "speed": 3.2, + "speedMin": 0.4, + "speedMax": 4.5, + "governor": "powersave", + "cores": 8, + "physicalCores": 4, + "performanceCores": 4, + "efficiencyCores": 0, + "processors": 1, + "socket": "", + "flags": "fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc art arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf tsc_known_freq pni pclmulqdq dtes64 monitor ds_cpl vmx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb cat_l2 invpcid_single cdp_l2 ssbd ibrs ibpb stibp ibrs_enhanced tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid rdt_a avx512f avx512dq rdseed adx smap avx512ifma clflushopt clwb intel_pt avx512cd sha_ni avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves split_lock_detect dtherm ida arat pln pts hwp hwp_notify hwp_act_window hwp_epp hwp_pkg_req avx512vbmi umip pku ospke avx512_vbmi2 gfni vaes vpclmulqdq avx512_vnni avx512_bitalg avx512_vpopcntdq rdpid movdiri movdir64b fsrm avx512_vp2intersect md_clear flush_l1d arch_capabilities", + "virtualization": true, + "cache": { + "l1d": 196608, + "l1i": 131072, + "l2": 5242880, + "l3": 8388608 + } + }, + "osInfo": { + "platform": "linux", + "distro": "nixos", + "release": "22.11", + "kernel": "5.10.177", + "arch": "x64" + }, + "system": { + "model": "Vostro 14 5410", + "manufacturer": "Dell Inc." + } +} \ No newline at end of file diff --git a/benches/suites/baseline_tcp/baseline_tcp_1KiB.ts b/benches/suites/baseline_tcp/baseline_tcp_1KiB.ts new file mode 100644 index 00000000..2b76290a --- /dev/null +++ b/benches/suites/baseline_tcp/baseline_tcp_1KiB.ts @@ -0,0 +1,57 @@ +import type { Host } from '../../../src/types'; +import * as net from 'net'; +import b from 'benny'; +import { promise } from '@/utils'; +import { suiteCommon, summaryName } from '../../utils'; + +async function main() { + // Setting up initial state + const data1KiB = Buffer.alloc(1024, 0xf0); + const host = '127.0.0.1' as Host; + + const listenProm = promise(); + + const server = net.createServer((socket) => { + // Noop to drop the data + socket.on('data', () => {}); + }); + server.listen(0, host, listenProm.resolveP); + + await listenProm.p; + + const address = server.address() as net.AddressInfo; + + const connectProm = promise(); + + const client = net.createConnection( + { + port: address.port, + host, + }, + connectProm.resolveP, + ); + + await connectProm.p; + + // Running benchmark + const summary = await b.suite( + summaryName(__filename), + b.add('send 1KiB of data over tcp', async () => { + const prom = promise(); + client.write(data1KiB, () => { + prom.resolveP(); + }); + await prom.p; + }), + ...suiteCommon, + ); + client.end(); + server.close(); + return summary; +} + +if (require.main === module) { + void main(); +} + +export default main; diff --git a/benches/suites/baseline_websocket/baseline_websocket_1KiB.ts b/benches/suites/baseline_websocket/baseline_websocket_1KiB.ts new file mode 100644 index 00000000..d7a0f84e --- /dev/null +++ b/benches/suites/baseline_websocket/baseline_websocket_1KiB.ts @@ -0,0 +1,62 @@ +import type { Host } from '../../../src/types'; +import type { AddressInfo } from 'net'; +import * as https from 'https'; +import b from 'benny'; +import * as ws from 'ws'; +import { promise } from '@/utils'; +import { suiteCommon, summaryName } from '../../utils'; +import * as testsUtils from '../../../tests/utils'; + +async function main() { + // Setting up initial state + const data1KiB = Buffer.alloc(1024, 0xf0); + const host = '127.0.0.1' as Host; + const tlsConfig = await testsUtils.generateConfig('RSA'); + + const listenProm = promise(); + + const httpsServer = https.createServer({ + ...tlsConfig, + }); + const wsServer = new ws.WebSocketServer({ + server: httpsServer, + }); + httpsServer.listen(0, host, listenProm.resolveP); + + await listenProm.p; + + const address = httpsServer.address() as AddressInfo; + + const openProm = promise(); + + const client = new ws.WebSocket(`wss://${host}:${address.port}`, { + rejectUnauthorized: false, + }); + + client.on('open', openProm.resolveP); + + await openProm.p; + + // Running benchmark + const summary = await b.suite( + summaryName(__filename), + b.add('send 1KiB of data over ws', async () => { + const sendProm = promise(); + client.send(data1KiB, { binary: true }, () => { + sendProm.resolveP(); + }); + await sendProm.p; + }), + ...suiteCommon, + ); + client.close(); + wsServer.close(); + httpsServer.close(); + return summary; +} + +if (require.main === module) { + void main(); +} + +export default main; diff --git a/benches/suites/connection/connection_1KiB.ts b/benches/suites/connection/connection_1KiB.ts new file mode 100644 index 00000000..5388bb39 --- /dev/null +++ b/benches/suites/connection/connection_1KiB.ts @@ -0,0 +1,66 @@ +import type { Host } from '../../../src/types'; +import b from 'benny'; +import Logger, { formatting, LogLevel, StreamHandler } from '@matrixai/logger'; +import { suiteCommon, summaryName } from '../../utils'; +import * as events from '../../../src/events'; +import * as testsUtils from '../../../tests/utils'; +import WebSocketServer from '../../../src/WebSocketServer'; +import WebSocketClient from '../../../src/WebSocketClient'; + +async function main() { + const logger = new Logger(`Stream1KB Bench`, LogLevel.WARN, [ + new StreamHandler( + formatting.format`${formatting.level}:${formatting.keys}:${formatting.msg}`, + ), + ]); + // Setting up initial state + const data1KiB = Buffer.alloc(1024, 0xf0); + const host = '127.0.0.1' as Host; + const tlsConfig = await testsUtils.generateConfig('RSA'); + + const wsServer = new WebSocketServer({ + config: { + key: tlsConfig.key, + cert: tlsConfig.cert, + }, + logger, + }); + + wsServer.addEventListener( + events.EventWebSocketServerConnection.name, + async (e: events.EventWebSocketServerConnection) => { + const conn = e.detail; + // @ts-ignore: protected property + conn.socket.removeAllListeners('message'); + }, + ); + await wsServer.start({ + host, + }); + const client = await WebSocketClient.createWebSocketClient({ + host, + port: wsServer.getPort(), + logger, + config: { + verifyPeer: false, + }, + }); + + // Running benchmark + const summary = await b.suite( + summaryName(__filename), + b.add('send 1KiB of data over connection', async () => { + await client.connection.send(data1KiB); + }), + ...suiteCommon, + ); + await wsServer.stop({ force: true }); + await client.destroy({ force: true }); + return summary; +} + +if (require.main === module) { + void main(); +} + +export default main; diff --git a/benches/suites/stream/stream_1KiB.ts b/benches/suites/stream/stream_1KiB.ts new file mode 100644 index 00000000..1c58b1d9 --- /dev/null +++ b/benches/suites/stream/stream_1KiB.ts @@ -0,0 +1,93 @@ +import type { Host } from '../../../src/types'; +import b from 'benny'; +import Logger, { formatting, LogLevel, StreamHandler } from '@matrixai/logger'; +import { suiteCommon, summaryName } from '../../utils'; +import * as events from '../../../src/events'; +import * as testsUtils from '../../../tests/utils'; +import WebSocketServer from '../../../src/WebSocketServer'; +import WebSocketClient from '../../../src/WebSocketClient'; + +async function main() { + const logger = new Logger(`Stream1KB Bench`, LogLevel.WARN, [ + new StreamHandler( + formatting.format`${formatting.level}:${formatting.keys}:${formatting.msg}`, + ), + ]); + // Setting up initial state + const data1KiB = Buffer.alloc(1024, 0xf0); + const host = '127.0.0.1' as Host; + const tlsConfig = await testsUtils.generateConfig('RSA'); + + const wsServer = new WebSocketServer({ + config: { + key: tlsConfig.key, + cert: tlsConfig.cert, + }, + logger, + }); + + wsServer.addEventListener( + events.EventWebSocketServerConnection.name, + async (e: events.EventWebSocketServerConnection) => { + const conn = e.detail; + conn.addEventListener( + events.EventWebSocketConnectionStream.name, + (streamEvent: events.EventWebSocketConnectionStream) => { + const stream = streamEvent.detail; + void Promise.allSettled([ + (async () => { + // Consume data + for await (const _ of stream.readable) { + // Do nothing, only consume + } + })(), + (async () => { + // End writable immediately + await stream.writable.close(); + })(), + ]); + }, + ); + }, + ); + await wsServer.start({ + host, + }); + const client = await WebSocketClient.createWebSocketClient({ + host, + port: wsServer.getPort(), + logger, + config: { + verifyPeer: false, + }, + }); + + const stream = await client.connection.newStream(); + const writer = stream.writable.getWriter(); + + const readProm = (async () => { + // Consume data + for await (const _ of stream.readable) { + // Do nothing, only consume + } + })(); + + // Running benchmark + const summary = await b.suite( + summaryName(__filename), + b.add('send 1KiB of data over stream', async () => { + await writer.write(data1KiB); + }), + ...suiteCommon, + ); + await wsServer.stop({ force: true }); + await client.destroy({ force: true }); + await readProm; + return summary; +} + +if (require.main === module) { + void main(); +} + +export default main; diff --git a/benches/utils.ts b/benches/utils.ts new file mode 100644 index 00000000..b8d7758a --- /dev/null +++ b/benches/utils.ts @@ -0,0 +1,100 @@ +import fs from 'fs'; +import path from 'path'; +import b from 'benny'; +import { codeBlock } from 'common-tags'; +import packageJson from '../package.json'; + +const suitesPath = path.join(__dirname, 'suites'); +const resultsPath = path.join(__dirname, 'results'); + +function summaryName(suitePath: string) { + return path + .relative(suitesPath, suitePath) + .replace(/\.[^.]*$/, '') + .replace(/\//g, '.'); +} + +const suiteCommon = [ + b.cycle(), + b.complete(), + b.save({ + file: (summary) => { + // Replace dots with slashes + const relativePath = summary.name.replace(/\./g, '/'); + // To `results/path/to/suite` + const resultPath = path.join(resultsPath, relativePath); + // This creates directory `results/path/to` + fs.mkdirSync(path.dirname(resultPath), { recursive: true }); + return relativePath; + }, + folder: resultsPath, + version: packageJson.version, + details: true, + }), + b.save({ + file: (summary) => { + // Replace dots with slashes + const relativePath = summary.name.replace(/\./g, '/'); + // To `results/path/to/suite` + const resultPath = path.join(resultsPath, relativePath); + // This creates directory `results/path/to` + fs.mkdirSync(path.dirname(resultPath), { recursive: true }); + return relativePath; + }, + folder: resultsPath, + version: packageJson.version, + format: 'chart.html', + }), + b.complete((summary) => { + // Replace dots with slashes + const relativePath = summary.name.replace(/\./g, '/'); + // To `results/path/to/suite_metrics.txt` + const resultPath = path.join(resultsPath, relativePath) + '_metrics.txt'; + // This creates directory `results/path/to` + fs.mkdirSync(path.dirname(resultPath), { recursive: true }); + fs.writeFileSync( + resultPath, + codeBlock` + # TYPE ${summary.name}_ops gauge + ${summary.results + .map( + (result) => + `${summary.name}_ops{name="${result.name}"} ${result.ops}`, + ) + .join('\n')} + + # TYPE ${summary.name}_margin gauge + ${summary.results + .map( + (result) => + `${summary.name}_margin{name="${result.name}"} ${result.margin}`, + ) + .join('\n')} + + # TYPE ${summary.name}_samples counter + ${summary.results + .map( + (result) => + `${summary.name}_samples{name="${result.name}"} ${result.samples}`, + ) + .join('\n')} + ` + '\n', + ); + // eslint-disable-next-line no-console + console.log('\nSaved to:', path.resolve(resultPath)); + }), +]; + +async function* fsWalk(dir: string): AsyncGenerator { + const dirents = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const dirent of dirents) { + const res = path.resolve(dir, dirent.name); + if (dirent.isDirectory()) { + yield* fsWalk(res); + } else { + yield res; + } + } +} + +export { suitesPath, resultsPath, summaryName, suiteCommon, fsWalk }; diff --git a/package-lock.json b/package-lock.json index 786b629c..5818cd2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,18 +10,25 @@ "license": "Apache-2.0", "dependencies": { "@matrixai/async-cancellable": "^1.1.1", - "@matrixai/async-init": "^1.8.4", + "@matrixai/async-init": "^1.10.0", "@matrixai/async-locks": "^4.0.0", - "@matrixai/contexts": "^1.1.0", - "@matrixai/errors": "^1.1.7", + "@matrixai/contexts": "^1.2.0", + "@matrixai/errors": "^1.2.0", + "@matrixai/events": "^3.2.0", "@matrixai/logger": "^3.1.0", "@matrixai/resources": "^1.1.5", "@matrixai/timer": "^1.1.1", "ip-num": "^1.5.0", + "resource-counter": "^1.2.4", "ws": "^8.13.0" }, "devDependencies": { - "@fast-check/jest": "^1.1.0", + "@fast-check/jest": "^1.7.1", + "@peculiar/asn1-pkcs8": "^2.3.0", + "@peculiar/asn1-schema": "^2.3.0", + "@peculiar/asn1-x509": "^2.3.0", + "@peculiar/webcrypto": "^1.4.0", + "@peculiar/x509": "^1.8.3", "@swc/core": "^1.3.62", "@swc/jest": "^0.2.26", "@types/jest": "^28.1.3", @@ -29,6 +36,7 @@ "@types/ws": "^8.5.5", "@typescript-eslint/eslint-plugin": "^5.45.1", "@typescript-eslint/parser": "^5.45.1", + "benny": "^3.7.1", "eslint": "^8.15.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", @@ -39,6 +47,7 @@ "prettier": "^2.6.2", "semver": "^7.3.7", "shx": "^0.3.4", + "systeminformation": "^5.21.4", "ts-jest": "^28.0.5", "ts-node": "^10.9.1", "tsconfig-paths": "^3.9.0", @@ -68,6 +77,48 @@ "node": ">=6.0.0" } }, + "node_modules/@arrows/array": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@arrows/array/-/array-1.4.1.tgz", + "integrity": "sha512-MGYS8xi3c4tTy1ivhrVntFvufoNzje0PchjEz6G/SsWRgUKxL4tKwS6iPdO8vsaJYldagAeWMd5KRD0aX3Q39g==", + "dev": true, + "dependencies": { + "@arrows/composition": "^1.2.2" + } + }, + "node_modules/@arrows/composition": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@arrows/composition/-/composition-1.2.2.tgz", + "integrity": "sha512-9fh1yHwrx32lundiB3SlZ/VwuStPB4QakPsSLrGJFH6rCXvdrd060ivAZ7/2vlqPnEjBkPRRXOcG1YOu19p2GQ==", + "dev": true + }, + "node_modules/@arrows/dispatch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@arrows/dispatch/-/dispatch-1.0.3.tgz", + "integrity": "sha512-v/HwvrFonitYZM2PmBlAlCqVqxrkIIoiEuy5bQgn0BdfvlL0ooSBzcPzTMrtzY8eYktPyYcHg8fLbSgyybXEqw==", + "dev": true, + "dependencies": { + "@arrows/composition": "^1.2.2" + } + }, + "node_modules/@arrows/error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@arrows/error/-/error-1.0.2.tgz", + "integrity": "sha512-yvkiv1ay4Z3+Z6oQsUkedsQm5aFdyPpkBUQs8vejazU/RmANABx6bMMcBPPHI4aW43VPQmXFfBzr/4FExwWTEA==", + "dev": true + }, + "node_modules/@arrows/multimethod": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@arrows/multimethod/-/multimethod-1.4.1.tgz", + "integrity": "sha512-AZnAay0dgPnCJxn3We5uKiB88VL+1ZIF2SjZohLj6vqY2UyvB/sKdDnFP+LZNVsTC5lcnGPmLlRRkAh4sXkXsQ==", + "dev": true, + "dependencies": { + "@arrows/array": "^1.4.1", + "@arrows/composition": "^1.2.2", + "@arrows/error": "^1.0.2", + "fast-deep-equal": "^3.1.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.22.10", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz", @@ -1655,12 +1706,13 @@ "integrity": "sha512-f0yxu7dHwvffZ++7aCm2WIcCJn18uLcOTdCCwEA3R3KVHYE3TG/JNoTWD9/mqBkAV1AI5vBfJzg27WnF9rOUXQ==" }, "node_modules/@matrixai/async-init": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/@matrixai/async-init/-/async-init-1.8.4.tgz", - "integrity": "sha512-33cGC7kHTs9KKwMHJA5d5XURWhx3QUq7lLxPEXLoVfWdTHixcWNvtfshAOso0hbRfx1P3ZSgsb+ZHaIASHhWfg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@matrixai/async-init/-/async-init-1.10.0.tgz", + "integrity": "sha512-JjUFu6rqd+dtTHFJ6z8bjbceuFGBj/APWfJByVsfbEH1DJsOgWERFcW3DBUrS0mgTph4Vl518tsNcsSwKT5Y+g==", "dependencies": { "@matrixai/async-locks": "^4.0.0", - "@matrixai/errors": "^1.1.7" + "@matrixai/errors": "^1.2.0", + "@matrixai/events": "^3.2.0" } }, "node_modules/@matrixai/async-locks": { @@ -1675,9 +1727,9 @@ } }, "node_modules/@matrixai/contexts": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@matrixai/contexts/-/contexts-1.1.0.tgz", - "integrity": "sha512-sB4UrT8T6OICBujNxTOss8O+dAHnbfndBqZG0fO1PSZUgaZlXDg3cSz9ButbV4JLEz25UvPgh4ChvwTP31DUcQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@matrixai/contexts/-/contexts-1.2.0.tgz", + "integrity": "sha512-MR/B02Kf4UoliP9b/gMMKsvWV6QM4JSPKTIqrhQP2tbOl3FwLI+AIhL3vgYEj1Xw+PP8bY5cr8ontJ8x6AJyMg==", "dependencies": { "@matrixai/async-cancellable": "^1.1.1", "@matrixai/async-locks": "^4.0.0", @@ -1687,13 +1739,21 @@ } }, "node_modules/@matrixai/errors": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@matrixai/errors/-/errors-1.1.7.tgz", - "integrity": "sha512-WD6MrlfgtNSTfXt60lbMgwasS5T7bdRgH4eYSOxV+KWngqlkEij9EoDt5LwdvcMD1yuC33DxPTnH4Xu2XV3nMw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@matrixai/errors/-/errors-1.2.0.tgz", + "integrity": "sha512-eZHPHFla5GFmi0O0yGgbtkca+ZjwpDbMz+60NC3y+DzQq6BMoe4gHmPjDalAHTxyxv0+Q+AWJTuV8Ows+IqBfQ==", "dependencies": { "ts-custom-error": "3.2.2" } }, + "node_modules/@matrixai/events": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@matrixai/events/-/events-3.2.0.tgz", + "integrity": "sha512-MiUr8cUQyGAZCU7EbbMZI1xiYtLnWi/FMSCYpuV+14cMtmU4qfZpCT/nUh+xUNZS3P/LOgR/VjW56BsrJTfICw==", + "engines": { + "node": ">=19.0.0" + } + }, "node_modules/@matrixai/logger": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@matrixai/logger/-/logger-3.1.0.tgz", @@ -1748,6 +1808,258 @@ "node": ">= 8" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.6.tgz", + "integrity": "sha512-Kr0XsyjuElTc4NijuPYyd6YkTlbz0KCuoWnNkfPFhXjHTzbUIh/s15ixjxLj8XDrXsI1aPQp3D64uHbrs3Kuyg==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/asn1-x509": "^2.3.6", + "@peculiar/asn1-x509-attr": "^2.3.6", + "asn1js": "^3.0.5", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/asn1-cms/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.3.6.tgz", + "integrity": "sha512-gCTEB/PvUxapmxo4SzGZT1JtEdevRnphRGZZmc9oJE7+pLuj2Px0Q6x+w8VvObfozA3pyPRTq+Wkocnu64+oLw==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/asn1-x509": "^2.3.6", + "asn1js": "^3.0.5", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/asn1-csr/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.6.tgz", + "integrity": "sha512-Hu1xzMJQWv8/GvzOiinaE6XiD1/kEhq2C/V89UEoWeZ2fLUcGNIvMxOr/pMyL0OmpRWj/mhCTXOZp4PP+a0aTg==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/asn1-x509": "^2.3.6", + "asn1js": "^3.0.5", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/asn1-ecc/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.3.6.tgz", + "integrity": "sha512-bScrrpQ59mppcoZLkDEW/Wruu+daSWQxpR2vqGjg69+v7VoQ1Le/Elm10ObfNShV2eNNridNQcOQvsHMLvUOCg==", + "dev": true, + "dependencies": { + "@peculiar/asn1-cms": "^2.3.6", + "@peculiar/asn1-pkcs8": "^2.3.6", + "@peculiar/asn1-rsa": "^2.3.6", + "@peculiar/asn1-schema": "^2.3.6", + "asn1js": "^3.0.5", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/asn1-pfx/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.3.6.tgz", + "integrity": "sha512-poqgdjsHNiyR0gnxP8l5VjRInSgpQvOM3zLULF/ZQW67uUsEiuPfplvaNJUlNqNOCd2szGo9jKW9+JmVVpWojA==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/asn1-x509": "^2.3.6", + "asn1js": "^3.0.5", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/asn1-pkcs8/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.3.6.tgz", + "integrity": "sha512-uaxSBF60glccuu5BEZvoPsaJzebVYcQRjXx2wXsGe7Grz/BXtq5RQAJ/3i9fEXawFK/zIbvbXBBpy07cnvrqhA==", + "dev": true, + "dependencies": { + "@peculiar/asn1-cms": "^2.3.6", + "@peculiar/asn1-pfx": "^2.3.6", + "@peculiar/asn1-pkcs8": "^2.3.6", + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/asn1-x509": "^2.3.6", + "@peculiar/asn1-x509-attr": "^2.3.6", + "asn1js": "^3.0.5", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/asn1-pkcs9/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.6.tgz", + "integrity": "sha512-DswjJyAXZnvESuImGNTvbNKvh1XApBVqU+r3UmrFFTAI23gv62byl0f5OFKWTNhCf66WQrd3sklpsCZc/4+jwA==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/asn1-x509": "^2.3.6", + "asn1js": "^3.0.5", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/asn1-rsa/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.6.tgz", + "integrity": "sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==", + "dev": true, + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/asn1-schema/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.6.tgz", + "integrity": "sha512-dRwX31R1lcbIdzbztiMvLNTDoGptxdV7HocNx87LfKU0fEWh7fTWJjx4oV+glETSy6heF/hJHB2J4RGB3vVSYg==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.6", + "asn1js": "^3.0.5", + "ipaddr.js": "^2.0.1", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.3.6.tgz", + "integrity": "sha512-x5Kax8xp3fz+JSc+4Sq0/SUXIdbJeOePibYqvjHMGkP6AoeCOVcP+gg7rZRRGkTlDSyQnAoUTgTEsfAfFEd1/g==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/asn1-x509": "^2.3.6", + "asn1js": "^3.0.5", + "tslib": "^2.4.0" + } + }, + "node_modules/@peculiar/asn1-x509-attr/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/asn1-x509/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/json-schema/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.4.3.tgz", + "integrity": "sha512-VtaY4spKTdN5LjJ04im/d/joXuvLbQdgy5Z4DXF4MFZhQ+MTrejbNMkfZBp1Bs3O5+bFqnJgyGdPuZQflvIa5A==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.2", + "tslib": "^2.5.0", + "webcrypto-core": "^1.7.7" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@peculiar/webcrypto/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/@peculiar/x509": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.9.5.tgz", + "integrity": "sha512-6HBrlgoyH8sod0PTjQ8hzOL4/f5L94s5lwiL9Gr0P5HiSO8eeNgKoiB+s7VhDczE2aaloAgDXFjoQHVEcTg4mg==", + "dev": true, + "dependencies": { + "@peculiar/asn1-cms": "^2.3.6", + "@peculiar/asn1-csr": "^2.3.6", + "@peculiar/asn1-ecc": "^2.3.6", + "@peculiar/asn1-pkcs9": "^2.3.6", + "@peculiar/asn1-rsa": "^2.3.6", + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/asn1-x509": "^2.3.6", + "pvtsutils": "^1.3.5", + "reflect-metadata": "^0.1.13", + "tslib": "^2.6.1", + "tsyringe": "^4.8.0" + } + }, + "node_modules/@peculiar/x509/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2697,6 +3009,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dev": true, + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/asn1js/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -2800,12 +3141,68 @@ "@babel/core": "^7.0.0" } }, + "node_modules/babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", + "dependencies": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/benchmark": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/benchmark/-/benchmark-2.1.4.tgz", + "integrity": "sha512-l9MlfN4M1K/H2fbhfMy3B7vJd6AGKJVQn2h6Sg/Yx+KckoUA7ewS5Vv6TjSq18ooE1kS9hhAlQRH3AkXIh/aOQ==", + "dev": true, + "dependencies": { + "lodash": "^4.17.4", + "platform": "^1.3.3" + } + }, + "node_modules/benny": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/benny/-/benny-3.7.1.tgz", + "integrity": "sha512-USzYxODdVfOS7JuQq/L0naxB788dWCiUgUTxvN+WLPt/JfcDURNNj8kN/N+uK6PDvuR67/9/55cVKGPleFQINA==", + "dev": true, + "dependencies": { + "@arrows/composition": "^1.0.0", + "@arrows/dispatch": "^1.0.2", + "@arrows/multimethod": "^1.1.6", + "benchmark": "^2.1.4", + "common-tags": "^1.8.0", + "fs-extra": "^10.0.0", + "json2csv": "^5.0.6", + "kleur": "^4.1.4", + "log-update": "^4.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/benny/node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/bitset": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bitset/-/bitset-5.1.1.tgz", + "integrity": "sha512-oKaRp6mzXedJ1Npo86PKhWfDelI6HxxJo+it9nAcBB0HLVvYVp+5i6yj6DT5hfFgo+TS5T57MRWtw8zhwdTs3g==", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2984,6 +3381,18 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3032,6 +3441,24 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3044,6 +3471,13 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, + "node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -3861,6 +4295,20 @@ "is-callable": "^1.1.3" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4283,6 +4731,15 @@ "resolved": "https://registry.npmjs.org/ip-num/-/ip-num-1.5.1.tgz", "integrity": "sha512-QziFxgxq3mjIf5CuwlzXFYscHxgLqdEdJKRo2UJ5GurL5zrSRMzT/O+nK0ABimoFH8MWF8YwIiwECYsHc1LpUQ==" }, + "node_modules/ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", @@ -6509,6 +6966,25 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json2csv": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-5.0.7.tgz", + "integrity": "sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "dependencies": { + "commander": "^6.1.0", + "jsonparse": "^1.3.1", + "lodash.get": "^4.4.2" + }, + "bin": { + "json2csv": "bin/json2csv.js" + }, + "engines": { + "node": ">= 10", + "npm": ">= 6.13.0" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6527,6 +7003,27 @@ "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", "dev": true }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ] + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6579,6 +7076,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -6591,6 +7100,38 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7094,6 +7635,12 @@ "node": ">=8" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "dev": true + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7213,6 +7760,30 @@ } ] }, + "node_modules/pvtsutils": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", + "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "dev": true, + "dependencies": { + "tslib": "^2.6.1" + } + }, + "node_modules/pvtsutils/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7251,6 +7822,17 @@ "node": ">= 0.10" } }, + "node_modules/reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==", + "dev": true + }, + "node_modules/regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", @@ -7333,6 +7915,31 @@ "node": ">=10" } }, + "node_modules/resource-counter": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/resource-counter/-/resource-counter-1.2.4.tgz", + "integrity": "sha512-DGJChvE5r4smqPE+xYNv9r1u/I9cCfRR5yfm7D6EQckdKqMyVpJ5z0s40yn0EM0puFxHg6mPORrQLQdEbJ/RnQ==", + "dependencies": { + "babel-runtime": "^6.26.0", + "bitset": "^5.0.3" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -7547,6 +8154,23 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7744,6 +8368,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/systeminformation": { + "version": "5.21.4", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.21.4.tgz", + "integrity": "sha512-fLW6j47UoAJDlZPEqykkWTKxubxb8IFuow6pMQlqf4irZ2lBgCrCReavMkH2t8VxxjOcg6wBlZ2EPQcluAT6xg==", + "dev": true, + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", @@ -7972,6 +8622,18 @@ "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, + "node_modules/tsyringe": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz", + "integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==", + "dev": true, + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -8143,6 +8805,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", @@ -8232,6 +8903,25 @@ "makeerror": "1.0.12" } }, + "node_modules/webcrypto-core": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.7.7.tgz", + "integrity": "sha512-7FjigXNsBfopEj+5DV2nhNpfic2vumtjjgPmeDKk45z+MJwXKKfhPB7118Pfzrmh4jqOMST6Ch37iPAHoImg5g==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.6", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.1", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.0" + } + }, + "node_modules/webcrypto-core/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 37e47936..c5231a53 100644 --- a/package.json +++ b/package.json @@ -23,25 +23,33 @@ "build": "shx rm -rf ./dist && tsc -p ./tsconfig.build.json", "ts-node": "ts-node", "test": "jest", - "lint": "eslint '{src,tests,scripts}/**/*.{js,ts}'", - "lintfix": "eslint '{src,tests,scripts}/**/*.{js,ts}' --fix", + "lint": "eslint '{src,tests,scripts,benches}/**/*.{js,ts}'", + "lintfix": "eslint '{src,tests,scripts,benches}/**/*.{js,ts}' --fix", "lint-shell": "find ./src ./tests ./scripts -type f -regextype posix-extended -regex '.*\\.(sh)' -exec shellcheck {} +", - "docs": "shx rm -rf ./docs && typedoc --gitRevision master --tsconfig ./tsconfig.build.json --out ./docs src" + "docs": "shx rm -rf ./docs && typedoc --gitRevision master --tsconfig ./tsconfig.build.json --out ./docs src", + "bench": "rimraf ./benches/results && ts-node ./benches" }, "dependencies": { "@matrixai/async-cancellable": "^1.1.1", - "@matrixai/async-init": "^1.8.4", + "@matrixai/async-init": "^1.10.0", "@matrixai/async-locks": "^4.0.0", - "@matrixai/contexts": "^1.1.0", - "@matrixai/errors": "^1.1.7", + "@matrixai/contexts": "^1.2.0", + "@matrixai/errors": "^1.2.0", + "@matrixai/events": "^3.2.0", "@matrixai/logger": "^3.1.0", "@matrixai/resources": "^1.1.5", "@matrixai/timer": "^1.1.1", "ip-num": "^1.5.0", + "resource-counter": "^1.2.4", "ws": "^8.13.0" }, "devDependencies": { - "@fast-check/jest": "^1.1.0", + "@fast-check/jest": "^1.7.1", + "@peculiar/asn1-pkcs8": "^2.3.0", + "@peculiar/asn1-schema": "^2.3.0", + "@peculiar/asn1-x509": "^2.3.0", + "@peculiar/webcrypto": "^1.4.0", + "@peculiar/x509": "^1.8.3", "@swc/core": "^1.3.62", "@swc/jest": "^0.2.26", "@types/jest": "^28.1.3", @@ -49,6 +57,7 @@ "@types/ws": "^8.5.5", "@typescript-eslint/eslint-plugin": "^5.45.1", "@typescript-eslint/parser": "^5.45.1", + "benny": "^3.7.1", "eslint": "^8.15.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", @@ -59,6 +68,7 @@ "prettier": "^2.6.2", "semver": "^7.3.7", "shx": "^0.3.4", + "systeminformation": "^5.21.4", "ts-jest": "^28.0.5", "ts-node": "^10.9.1", "tsconfig-paths": "^3.9.0", diff --git a/pkgs.nix b/pkgs.nix index bb501409..2997ae2e 100644 --- a/pkgs.nix +++ b/pkgs.nix @@ -1,4 +1,4 @@ import ( - let rev = "f294325aed382b66c7a188482101b0f336d1d7db"; in + let rev = "ea5234e7073d5f44728c499192544a84244bf35a"; in builtins.fetchTarball "https://github.com/NixOS/nixpkgs/archive/${rev}.tar.gz" ) diff --git a/shell.nix b/shell.nix index 5839479a..4cfef47a 100644 --- a/shell.nix +++ b/shell.nix @@ -3,7 +3,7 @@ with pkgs; mkShell { nativeBuildInputs = [ - nodejs + nodejs_20 shellcheck gitAndTools.gh ]; diff --git a/src/WebSocketClient.ts b/src/WebSocketClient.ts index 3cf9665a..e3d0733d 100644 --- a/src/WebSocketClient.ts +++ b/src/WebSocketClient.ts @@ -1,5 +1,317 @@ -class WebSocketClient { +import type { ResolveHostname, WebSocketClientConfigInput } from './types'; +import type { ContextTimed, ContextTimedInput } from '@matrixai/contexts'; +import { AbstractEvent } from '@matrixai/events'; +import { createDestroy } from '@matrixai/async-init'; +import Logger from '@matrixai/logger'; +import WebSocket from 'ws'; +import { EventAll } from '@matrixai/events'; +import { context, timedCancellable } from '@matrixai/contexts/dist/decorators'; +import * as errors from './errors'; +import WebSocketConnection from './WebSocketConnection'; +import WebSocketConnectionMap from './WebSocketConnectionMap'; +import { clientDefault, connectTimeoutTime } from './config'; +import * as events from './events'; +import * as utils from './utils'; +interface WebSocketClient extends createDestroy.CreateDestroy {} +/** + * You must provide an error handler `addEventListener('error')`. + * Otherwise, errors will just be ignored. + * + * Events: + * - {@link events.EventWebSocketClientDestroy} + * - {@link events.EventWebSocketClientDestroyed} + * - {@link events.EventWebSocketClientError} - includes re-dispatched {@link events.EventWebSocketConnectionError} + * - {@link events.EventWebSocketClientClose} + * - {@link events.EventWebSocketConnection} - all dispatched events from {@link WebSocketConnection} + */ +@createDestroy.CreateDestroy({ + eventDestroy: events.EventWebSocketClientDestroy, + eventDestroyed: events.EventWebSocketClientDestroyed, +}) +class WebSocketClient extends EventTarget { + /** + * Creates a WebSocketClient + * + * @param obj + * @param obj.host - target host address to connect to + * @param obj.port - target port to connect to + * @param obj.config - optional config + * @param obj.logger - optional logger + * + * @throws {errors.ErrorWebSocketHostInvalid} + * @throws {errors.ErrorWebSocketPortInvalid} + * @throws {errors.ErrorWebSocketConnection} - re-dispatched from {@link WebSocketConnection} + */ + public static async createWebSocketClient( + { + host, + port, + config, + logger = new Logger(`${this.name}`), + }: { + host: string; + port: number; + config?: WebSocketClientConfigInput; + logger?: Logger; + }, + ctx?: Partial, + ): Promise; + @timedCancellable( + true, + connectTimeoutTime, + errors.ErrorWebSocketClientCreateTimeOut, + ) + public static async createWebSocketClient( + { + host, + port, + config, + resolveHostname = utils.resolveHostname, + logger = new Logger(`${this.name}`), + }: { + host: string; + port: number; + config?: WebSocketClientConfigInput; + resolveHostname?: ResolveHostname; + logger?: Logger; + }, + @context ctx: ContextTimed, + ): Promise { + logger.info(`Create ${this.name} to ${host}:${port}`); + const wsConfig = { + ...clientDefault, + ...config, + }; + + let [host_] = await utils.resolveHost(host, resolveHostname); + const port_ = utils.toPort(port); + // If the target host is in fact a zero IP, it cannot be used + // as a target host, so we need to resolve it to a non-zero IP + // in this case, 0.0.0.0 is resolved to 127.0.0.1 and :: and ::0 is + // resolved to ::1 + host_ = utils.resolvesZeroIP(host_); + + const address = `wss://${utils.buildAddress(host_, port_)}`; + + // RejectUnauthorized must be false when TLSVerifyCallback exists, + // This is so that verification can be deferred to the callback rather than the system installed Certs + const webSocket = new WebSocket(address, { + rejectUnauthorized: + wsConfig.verifyPeer && wsConfig.verifyCallback == null, + key: wsConfig.key as any, + cert: wsConfig.cert as any, + ca: wsConfig.ca as any, + }); + + const connectionId = 0; + const connection = new WebSocketConnection({ + type: 'client', + connectionId, + config: wsConfig, + socket: webSocket, + logger: logger.getChild(`${WebSocketConnection.name} ${connectionId}`), + }); + const client = new this({ + address, + logger, + connection, + }); + // Setting up connection events + connection.addEventListener( + events.EventWebSocketConnectionStopped.name, + client.handleEventWebSocketConnectionStopped, + { once: true }, + ); + connection.addEventListener( + EventAll.name, + client.handleEventWebSocketConnection, + ); + connection.addEventListener( + events.EventWebSocketConnectionError.name, + client.handleEventWebSocketConnectionError, + ); + // Setting up client events + client.addEventListener( + events.EventWebSocketClientError.name, + client.handleEventWebSocketClientError, + ); + client.addEventListener( + events.EventWebSocketClientClose.name, + client.handleEventWebSocketClientClose, + { once: true }, + ); + + client.connectionMap.add(connection); + + try { + await connection.start(ctx); + } catch (e) { + client.connectionMap.delete(connectionId); + connection.removeEventListener( + events.EventWebSocketConnectionStopped.name, + client.handleEventWebSocketConnectionStopped, + ); + connection.removeEventListener( + EventAll.name, + client.handleEventWebSocketConnection, + ); + + client.removeEventListener( + events.EventWebSocketClientError.name, + client.handleEventWebSocketClientError, + ); + client.removeEventListener( + events.EventWebSocketClientClose.name, + client.handleEventWebSocketClientClose, + ); + throw e; + } + + logger.info(`Created ${this.name} to ${address}`); + return client; + } + + /** + * The connection of the client. + */ + public readonly connection: WebSocketConnection; + /** + * Resolved when the underlying server is closed. + */ + public readonly closedP: Promise; + + protected logger: Logger; + + /** + * Map of connections with connectionId keys that correspond to WebSocketConnection values. + */ + public readonly connectionMap: WebSocketConnectionMap = + new WebSocketConnectionMap(); + protected address: string; + protected _closed: boolean = false; + protected resolveClosedP: () => void; + + protected handleEventWebSocketClientError = async ( + evt: events.EventWebSocketClientError, + ) => { + const error = evt.detail; + this.logger.error(utils.formatError(error)); + }; + + protected handleEventWebSocketClientClose = async () => { + await this.connection.stop({ force: true }); + this._closed = true; + this.resolveClosedP(); + if (!this[createDestroy.destroyed]) { + // Failing this is a software error + await this.destroy({ force: true }); + } + }; + + protected handleEventWebSocketConnectionStopped = async ( + evt: events.EventWebSocketConnectionStopped, + ) => { + const connection = evt.target as WebSocketConnection; + connection.removeEventListener( + EventAll.name, + this.handleEventWebSocketConnection, + ); + connection.addEventListener( + events.EventWebSocketConnectionError.name, + this.handleEventWebSocketConnectionError, + ); + this.connectionMap.delete(connection.connectionId); + this.dispatchEvent(new events.EventWebSocketClientClose()); + }; + + protected handleEventWebSocketConnection = (evt: EventAll) => { + if (evt.detail instanceof AbstractEvent) { + this.dispatchEvent(evt.detail.clone()); + } + }; + + /** + * All connection errors are redispatched as client errors. + * Connection errors encompass both graceful closes and non-graceful closes. + * This also includes `ErrorWebSocketConnectionInternal` + */ + protected handleEventWebSocketConnectionError = ( + evt: events.EventWebSocketConnectionError, + ) => { + const error = evt.detail; + this.dispatchEvent(new events.EventWebSocketClientError({ detail: error })); + }; + + /** + * Boolean that indicates whether the internal server is closed or not. + */ + public get closed() { + return this._closed; + } + + /** + * Constructor + * @param opts + * @param opts.address - the address to connect to + * @param opts.logger - injected logger + * @param opts.connection - injected connection + * @internal + */ + public constructor({ + address, + logger, + connection, + }: { + address: string; + logger: Logger; + connection: WebSocketConnection; + }) { + super(); + this.address = address; + this.logger = logger; + this.connection = connection; + const { p: closedP, resolveP: resolveClosedP } = utils.promise(); + this.closedP = closedP; + this.resolveClosedP = resolveClosedP; + } + + /** + * Destroys WebSocketClient + * @param opts + * @param opts.errorCode - The error code to send to connections on closing + * @param opts.errorMessage - The error message to send to connections on closing + * @param opts.force - When force is false, the returned promise will wait for all streams and connections to close naturally before resolving. + */ + public async destroy({ + errorCode = utils.ConnectionErrorCode.Normal, + errorMessage = '', + force = true, + }: { + errorCode?: number; + errorMessage?: string; + force?: boolean; + } = {}) { + this.logger.info(`Destroy ${this.constructor.name} on ${this.address}`); + if (!this._closed) { + // Failing this is a software error + await this.connection.stop({ + errorCode, + reason: errorMessage, + force, + }); + this.dispatchEvent(new events.EventWebSocketClientClose()); + } + await this.closedP; + this.removeEventListener( + events.EventWebSocketClientError.name, + this.handleEventWebSocketClientError, + ); + this.removeEventListener( + events.EventWebSocketClientClose.name, + this.handleEventWebSocketClientClose, + ); + } } export default WebSocketClient; diff --git a/src/WebSocketConnection.ts b/src/WebSocketConnection.ts index 643ee7fd..a746389f 100644 --- a/src/WebSocketConnection.ts +++ b/src/WebSocketConnection.ts @@ -1,5 +1,930 @@ +import type { PromiseCancellable } from '@matrixai/async-cancellable'; +import type { ContextTimed, ContextTimedInput } from '@matrixai/contexts'; +import type { + ConnectionMetadata, + Host, + Port, + StreamCodeToReason, + StreamReasonToCode, + WebSocketConfig, +} from './types'; +import type { TLSSocket } from 'tls'; +import type { StreamId } from './message'; +import { startStop } from '@matrixai/async-init'; +import { Lock } from '@matrixai/async-locks'; +import { context, timedCancellable } from '@matrixai/contexts/dist/decorators'; +import Logger from '@matrixai/logger'; +import * as ws from 'ws'; +import { Timer } from '@matrixai/timer'; +import { AbstractEvent, EventAll } from '@matrixai/events'; +import { concatUInt8Array } from './message'; +import WebSocketStream from './WebSocketStream'; +import * as errors from './errors'; +import * as events from './events'; +import { parseStreamId, StreamMessageType } from './message'; +import * as utils from './utils'; +import { connectTimeoutTime } from './config'; + +interface WebSocketConnection extends startStop.StartStop {} +/** + * Think of this as equivalent to `net.Socket`. + * This is one-to-one with the ws.WebSocket. + * Errors here are emitted to the connection only. + * Not to the server. + * + * Events: + * - {@link events.EventWebSocketConnectionStream} + * - {@link events.EventWebSocketConnectionStart} + * - {@link events.EventWebSocketConnectionStarted} + * - {@link events.EventWebSocketConnectionStop} + * - {@link events.EventWebSocketConnectionStopped} + * - {@link events.EventWebSocketConnectionError} - can occur due to a timeout too + * - {@link events.EventWebSocketConnectionClose} + * - {@link events.EventWebSocketStream} - all dispatched events from {@link WebSocketStream} + * + * Note that on TLS verification failure, {@link events.EventWebSocketConnectionError} is emitted with the following `event.detail`: + * - If we failed to verify the peer, the `event.detail` will be an instance of {@link errors.ErrorWebSocketConnectionLocalTLS}. + * - If the peer failed to verify us, the `event.detail` will be an instance of {@link errors.ErrorWebSocketConnectionPeer} with an error code of {@link utils.ConnectionErrorCode.AbnormalClosure}. + * + * The reason for this is that when the peer fails to verify us, Node only tells us that the TCP socket has been reset, but not why. + */ +@startStop.StartStop({ + eventStart: events.EventWebSocketConnectionStart, + eventStarted: events.EventWebSocketConnectionStarted, + eventStop: events.EventWebSocketConnectionStop, + eventStopped: events.EventWebSocketConnectionStopped, +}) class WebSocketConnection { + /** + * This determines when it is a client or server connection. + */ + public readonly type: 'client' | 'server'; + + /** + * This is the source connection ID. + */ + public readonly connectionId: number; + + /** + * Internal stream map. + * @internal + */ + protected streamMap: Map = new Map(); + + protected logger: Logger; + + /** + * Internal native WebSocket object. + * @internal + */ + protected socket: ws.WebSocket; + + protected config: WebSocketConfig; + + /** + * Converts reason to code. + * Used during `WebSocketStream` creation. + */ + protected reasonToCode: StreamReasonToCode; + + /** + * Converts code to reason. + * Used during `WebSocketStream` creation. + */ + protected codeToReason: StreamCodeToReason; + + /** + * Stream ID increment lock. + */ + protected streamIdLock: Lock = new Lock(); + + /** + * Client initiated bidirectional stream starts at 0. + * Increment by 4 to get the next ID. + */ + protected streamIdClientBidi: StreamId = 0b00n as StreamId; + + /** + * Server initiated bidirectional stream starts at 1. + * Increment by 4 to get the next ID. + */ + protected streamIdServerBidi: StreamId = 0b01n as StreamId; + + protected keepAliveTimeOutTimer?: Timer; + protected keepAliveIntervalTimer?: Timer; + + protected _remoteHost: Host; + protected _remotePort: Port; + protected _localHost?: Host; + protected _localPort?: Port; + protected certDERs: Array = []; + protected caDERs: Array = []; + protected remoteCertDERs: Array = []; + + /** + * Secure connection establishment. + * This can resolve or reject. + * Will resolve after connection has established and peer certs have been validated. + * Rejections cascade down to `secureEstablishedP` and `closedP`. + */ + protected secureEstablished = false; + protected secureEstablishedP: Promise; + protected resolveSecureEstablishedP: () => void; + protected rejectSecureEstablishedP: (reason?: any) => void; + + protected socketLocallyClosed: boolean = false; + protected closeSocket: (errorCode?: number, reason?: string) => void; + /** + * Connection closed promise. + * This can resolve or reject. + */ + public readonly closedP: Promise; + protected resolveClosedP: () => void; + + /** + * This stores the last dispatched error. + * If no error has occurred, it will be `null`. + */ + protected errorLast: + | errors.ErrorWebSocketConnectionLocal + | errors.ErrorWebSocketConnectionPeer + | errors.ErrorWebSocketConnectionKeepAliveTimeOut + | errors.ErrorWebSocketConnectionInternal + | null = null; + + protected handleEventWebSocketConnectionError = ( + evt: events.EventWebSocketConnectionError, + ) => { + const error = evt.detail; + this.errorLast = error; + // In the case of graceful exit, we don't want to log out the error + if ( + (error instanceof errors.ErrorWebSocketConnectionLocal || + error instanceof errors.ErrorWebSocketConnectionPeer) && + error.data?.errorCode === utils.ConnectionErrorCode.Normal + ) { + this.logger.info(utils.formatError(error)); + } else { + this.logger.error(utils.formatError(error)); + } + // If the error is an internal error, throw it to become `EventError` + // By default this will become an uncaught exception + // Cannot attempt to close the connection, because an internal error is unrecoverable + if (error instanceof errors.ErrorWebSocketConnectionInternal) { + // Use `EventError` to deal with this + throw error; + } + this.dispatchEvent( + new events.EventWebSocketConnectionClose({ + detail: error, + }), + ); + }; + + protected handleEventWebSocketConnectionClose = async ( + evt: events.EventWebSocketConnectionClose, + ) => { + const error = evt.detail; + if (!this.secureEstablished) { + this.rejectSecureEstablishedP(error); + } + if (this[startStop.running] && this[startStop.status] !== 'stopping') { + // Failing to force stop is a software error + await this.stop({ + force: true, + }); + } + }; + + protected handleEventWebSocketStream = (evt: EventAll) => { + if (evt.detail instanceof AbstractEvent) { + this.dispatchEvent(evt.detail.clone()); + } + }; + + protected handleEventWebSocketStreamSend = async ( + evt: events.EventWebSocketStreamSend, + ) => { + await this.send(evt.msg); + }; + + protected handleEventWebSocketStreamStopped = ( + evt: events.EventWebSocketStreamStopped, + ) => { + const stream = evt.target as WebSocketStream; + stream.removeEventListener( + events.EventWebSocketStreamSend.name, + this.handleEventWebSocketStreamSend, + ); + stream.removeEventListener(EventAll.name, this.handleEventWebSocketStream); + this.streamMap.delete(stream.streamId); + }; + + protected handleSocketMessage = async ( + data: ws.RawData, + isBinary: boolean, + ) => { + if (!isBinary || data instanceof Array) { + const reason = "WebSocket received data received that wasn't binary"; + this.dispatchEvent( + new events.EventWebSocketConnectionError({ + detail: new errors.ErrorWebSocketConnectionLocal(reason, { + cause: new errors.ErrorWebSocketUndefinedBehaviour(), + data: { + errorCode: utils.ConnectionErrorCode.InternalServerError, + reason, + }, + }), + }), + ); + return; + } + let remainder: Uint8Array = + data instanceof ArrayBuffer ? new Uint8Array(data) : data; + + let streamId; + try { + const { data: parsedStreamId, remainder: postStreamIdRemainder } = + parseStreamId(remainder); + streamId = parsedStreamId; + remainder = postStreamIdRemainder; + } catch (e) { + // TODO: domain specific error + const reason = 'Parsing streamId failed'; + this.dispatchEvent( + new events.EventWebSocketConnectionError({ + detail: new errors.ErrorWebSocketConnectionLocal(reason, { + cause: e, + data: { + errorCode: utils.ConnectionErrorCode.InternalServerError, + reason, + }, + }), + }), + ); + return; + } + + let stream = this.streamMap.get(streamId); + if (stream == null) { + // Because the stream code is 16 bits, and Ack is only the right-most bit set when encoded by big-endian, + // we can assume that the second byte of the StreamMessageType.Ack will look the same as if it were encoded in a u8 + if ( + !(remainder.at(1) === StreamMessageType.Ack && remainder.at(0) === 0) + ) { + return; + } + stream = new WebSocketStream({ + initiated: 'peer', + connection: this, + streamId, + bufferSize: this.config.streamBufferSize, + reasonToCode: this.reasonToCode, + codeToReason: this.codeToReason, + logger: this.logger.getChild(`${WebSocketStream.name} ${streamId!}`), + }); + this.streamMap.set(streamId, stream); + stream.addEventListener( + events.EventWebSocketStreamSend.name, + this.handleEventWebSocketStreamSend, + ); + stream.addEventListener( + events.EventWebSocketStreamStopped.name, + this.handleEventWebSocketStreamStopped, + { once: true }, + ); + stream.addEventListener(EventAll.name, this.handleEventWebSocketStream); + await stream.start(); + this.dispatchEvent( + new events.EventWebSocketConnectionStream({ + detail: stream, + }), + ); + } + + await stream!.streamRecv(remainder); + }; + + protected handleSocketPing = () => { + this.socket.pong(); + }; + + protected handleSocketPong = () => { + this.setKeepAliveTimeoutTimer(); + }; + + protected handleSocketClose = (errorCode: number, reason: Buffer) => { + this.resolveClosedP(); + // If this connection isn't closed by the peer, we don't need to event that it's closed + if (this.socketLocallyClosed) { + return; + } + // No need to close socket, already closed on receiving event + const e_ = new errors.ErrorWebSocketConnectionPeer( + `Peer closed with code ${errorCode}`, + { + data: { + errorCode, + reason: reason.toString('utf-8'), + }, + }, + ); + this.dispatchEvent( + new events.EventWebSocketConnectionError({ + detail: e_, + }), + ); + }; + + protected handleSocketError = (err: Error) => { + const errorCode = utils.ConnectionErrorCode.InternalServerError; + const reason = 'An error occurred on the underlying WebSocket instance'; + this.closeSocket(errorCode, reason); + const e_ = new errors.ErrorWebSocketConnectionInternal(reason, { + cause: err, + data: { + errorCode, + reason, + }, + }); + this.dispatchEvent( + new events.EventWebSocketConnectionError({ + detail: e_, + }), + ); + }; + + /** + * Gets an array of local certificates in DER format starting on the leaf. + * + * This will be empty if: + * - the connection was instantiated by a `WebSocketServer` with an injected `https.Server` or + * - the connection was instantiated by a `WebSocketClient` running in a browser + */ + public getLocalCertsChain(): Array { + return this.certDERs; + } + + /** + * Gets an array of CA certificates in DER format starting on the leaf. + * + * This will be empty if: + * - the connection was instantiated by a `WebSocketServer` with an injected `https.Server` or + * - the connection was instantiated by a `WebSocketClient` running in a browser + */ + public getLocalCACertsChain(): Array { + return this.caDERs; + } + + /** + * Gets an array of peer certificates in DER format starting on the leaf. + * + * This will be empty if the connection was instantiated by a `WebSocketClient` running in a browser. + */ + public getRemoteCertsChain(): Array { + return this.remoteCertDERs; + } + + /** + * Gets the connection metadata. + * + * Some certs may be unavailable with certain injected config options or on certain platforms, please @see: + * - {@link WebSocketConnection.getLocalCertsChain} + * - {@link WebSocketConnection.getLocalCACertsChain} + * - {@link WebSocketConnection.getRemoteCertsChain} + */ + @startStop.ready(new errors.ErrorWebSocketConnectionNotRunning()) + public meta(): ConnectionMetadata { + return { + localHost: this._localHost, + localPort: this._localPort, + remoteHost: this._remoteHost, + remotePort: this._remotePort, + localCACertsChain: this.caDERs, + localCertsChain: this.certDERs, + remoteCertsChain: this.remoteCertDERs, + }; + } + + public constructor({ + type, + connectionId, + meta, + config, + socket, + reasonToCode = () => 0n, + codeToReason = (type, code) => new Error(`${type} ${code}`), + logger, + }: + | { + type: 'client'; + connectionId: number; + meta?: undefined; + config: WebSocketConfig; + socket: ws.WebSocket; + reasonToCode?: StreamReasonToCode; + codeToReason?: StreamCodeToReason; + logger?: Logger; + } + | { + type: 'server'; + connectionId: number; + meta: ConnectionMetadata; + config: WebSocketConfig; + socket: ws.WebSocket; + reasonToCode?: StreamReasonToCode; + codeToReason?: StreamCodeToReason; + logger?: Logger; + }) { + this.logger = logger ?? new Logger(`${this.constructor.name}`); + this.connectionId = connectionId; + this.socket = socket; + this.config = config; + this.type = type; + this.reasonToCode = reasonToCode; + this.codeToReason = codeToReason; + + if (meta != null) { + this._remoteHost = meta.remoteHost as Host; + this._remotePort = meta.remotePort as Port; + this._localHost = meta.localHost as Host | undefined; + this._localPort = meta.localPort as Port | undefined; + this.caDERs = meta.localCACertsChain; + this.certDERs = meta.localCertsChain; + this.remoteCertDERs = meta.remoteCertsChain; + } + + const { + p: secureEstablishedP, + resolveP: resolveSecureEstablishedP, + rejectP: rejectSecureEstablishedP, + } = utils.promise(); + this.secureEstablishedP = secureEstablishedP; + this.resolveSecureEstablishedP = () => { + // This is an idempotent mutation + this.secureEstablished = true; + resolveSecureEstablishedP(); + }; + this.rejectSecureEstablishedP = rejectSecureEstablishedP; + + const { p: closedP, resolveP: resolveClosedP } = utils.promise(); + this.closedP = closedP; + this.resolveClosedP = resolveClosedP; + + this.closeSocket = (errorCode, reason) => { + this.socketLocallyClosed = true; + this.socket.close(errorCode, reason); + }; + } + + /** + * The host of the peer. + */ + public get remoteHost(): Host { + return this._remoteHost; + } + + /** + * The port of the peer. + */ + public get remotePort(): Port { + return this._remotePort; + } + + /** + * The local host of the socket. + */ + public get localHost(): Host | undefined { + return this._localHost; + } + + /** + * The local port of the socket. + */ + public get localPort(): Port | undefined { + return this._localPort; + } + + /** + * Whether the underlying WebSocket has been closed. + */ + public get closed() { + return this.socket.readyState === ws.CLOSED; + } + + /** + * Start the connection. + * @param ctx + * @internal + */ + public start(ctx?: Partial): PromiseCancellable; + @timedCancellable( + true, + connectTimeoutTime, + errors.ErrorWebSocketConnectionStartTimeOut, + ) + public async start(@context ctx: ContextTimed): Promise { + this.logger.info(`Start ${this.constructor.name}`); + if (this.socket.readyState === ws.CLOSED) { + throw new errors.ErrorWebSocketConnectionClosed(); + } + // Are we supposed to throw? + // It depends, if the connection start is aborted + // In a way, it makes sense for it be thrown + // It doesn't just simply complete + ctx.signal.throwIfAborted(); + const { p: abortP, rejectP: rejectAbortP } = utils.promise(); + const abortHandler = () => { + rejectAbortP(ctx.signal.reason); + }; + ctx.signal.addEventListener('abort', abortHandler); + this.addEventListener( + events.EventWebSocketConnectionError.name, + this.handleEventWebSocketConnectionError, + ); + this.addEventListener( + events.EventWebSocketConnectionClose.name, + this.handleEventWebSocketConnectionClose, + { once: true }, + ); + + // If the socket is already open, then the it is already secure and established by the WebSocketServer + if (this.socket.readyState === ws.OPEN) { + this.resolveSecureEstablishedP(); + } + // Handle connection failure - Dispatch ConnectionError -> ConnectionClose -> rejectSecureEstablishedP + const openErrorHandler = (e) => { + let e_: errors.ErrorWebSocketConnection; + let reason: string; + let errorCode: number; + switch (e.code) { + case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE': + errorCode = utils.ConnectionErrorCode.TLSHandshake; + reason = + "WebSocket could not open due to failure to verify a peer's TLS certificate"; + e_ = new errors.ErrorWebSocketConnectionLocalTLS(reason, { + cause: e, + data: { + errorCode, + reason, + }, + }); + break; + case 'ECONNRESET': + reason = 'WebSocket could not open due to socket closure by peer'; + (errorCode = utils.ConnectionErrorCode.AbnormalClosure), + (e_ = new errors.ErrorWebSocketConnectionPeer(reason, { + cause: e, + data: { + errorCode, + reason, + }, + })); + break; + default: + reason = 'WebSocket could not open due to internal error'; + (errorCode = utils.ConnectionErrorCode.InternalServerError), + (e_ = new errors.ErrorWebSocketConnectionLocal(reason, { + cause: e, + data: { + errorCode, + reason, + }, + })); + break; + } + this.closeSocket(errorCode, reason); + this.dispatchEvent( + new events.EventWebSocketConnectionError({ + detail: e_, + }), + ); + }; + this.socket.once('error', openErrorHandler); + const openHandler = () => { + this.resolveSecureEstablishedP(); + }; + this.socket.once('open', openHandler); + // This will always happen, no need to remove the handler + this.socket.once('close', this.handleSocketClose); + + if (this.type === 'client') { + this.socket.once('upgrade', async (request) => { + const tlsSocket = request.socket as TLSSocket; + const peerCert = tlsSocket.getPeerCertificate(true); + const peerCertChain = utils.toPeerCertChain(peerCert); + const localCertChain = utils + .collectPEMs(this.config.cert) + .map(utils.pemToDER); + const ca = utils.collectPEMs(this.config.ca).map(utils.pemToDER); + try { + if (this.config.verifyPeer && this.config.verifyCallback != null) { + await this.config.verifyCallback?.(peerCertChain, ca); + } + this._localHost = request.connection.localAddress as Host; + this._localPort = request.connection.localPort as Port; + this._remoteHost = request.connection.remoteAddress as Host; + this._remotePort = request.connection.remotePort as Port; + this.caDERs = ca; + this.certDERs = localCertChain; + this.remoteCertDERs = peerCertChain; + } catch (e) { + const errorCode = utils.ConnectionErrorCode.TLSHandshake; + const reason = + 'Failed connection due to custom verification callback'; + // Request.destroy() will make the socket dispatch a 'close' event, + // so I'm setting socketLocallyClosed to true, as that is what is happening. + this.socketLocallyClosed = true; + request.destroy(e); + const e_ = new errors.ErrorWebSocketConnectionLocalTLS(reason, { + cause: e, + data: { + errorCode, + reason, + }, + }); + this.dispatchEvent( + new events.EventWebSocketConnectionError({ + detail: e_, + }), + ); + } + }); + } + + // Wait for open + // This should only reject with ErrorWebSocketConnectionLocal. + // This can either be from a ws.WebSocket error, or abort signal, or TLS handshake error + try { + await Promise.race([this.secureEstablishedP, abortP]); + } catch (e) { + // This happens if a timeout occurs. + if (ctx.signal.aborted) { + const errorCode = utils.ConnectionErrorCode.ProtocolError; + const reason = + 'Failed to start WebSocket connection due to start timeout'; + this.closeSocket(errorCode, reason); + const e_ = new errors.ErrorWebSocketConnectionLocal(reason, { + cause: e, + data: { + errorCode, + reason, + }, + }); + this.dispatchEvent( + new events.EventWebSocketConnectionError({ + detail: e_, + }), + ); + } + + this.socket.off('open', openHandler); + // Upgrade only exists on the ws library, we can use removeAllListeners without worrying + this.socket.removeAllListeners('upgrade'); + // Close the ws if it's open at this stage + await this.closedP; + throw e; + } finally { + ctx.signal.removeEventListener('abort', abortHandler); + // Upgrade has already been removed by being called once or by the catch + this.socket.off('error', openErrorHandler); + } + + // Set the connection up + this.socket.on('message', this.handleSocketMessage); + this.socket.on('ping', this.handleSocketPing); + this.socket.on('pong', this.handleSocketPong); + this.socket.once('error', this.handleSocketError); + + if (this.config.keepAliveIntervalTime != null) { + this.startKeepAliveIntervalTimer(this.config.keepAliveIntervalTime); + } + if (this.config.keepAliveTimeoutTime != null) { + this.setKeepAliveTimeoutTimer(); + } + + this.logger.info(`Started ${this.constructor.name}`); + } + + /** + * Creates a new bidirectional WebSocketStream. + */ + @startStop.ready(new errors.ErrorWebSocketConnectionNotRunning()) + public async newStream(): Promise { + return await this.streamIdLock.withF(async () => { + let streamId: StreamId; + if (this.type === 'client') { + streamId = this.streamIdClientBidi; + } else if (this.type === 'server') { + streamId = this.streamIdServerBidi; + } + const stream = new WebSocketStream({ + initiated: 'local', + streamId: streamId!, + connection: this, + bufferSize: this.config.streamBufferSize, + codeToReason: this.codeToReason, + reasonToCode: this.reasonToCode, + logger: this.logger.getChild(`${WebSocketStream.name} ${streamId!}`), + }); + this.streamMap.set(streamId!, stream); + stream.addEventListener( + events.EventWebSocketStreamSend.name, + this.handleEventWebSocketStreamSend, + ); + stream.addEventListener( + events.EventWebSocketStreamStopped.name, + this.handleEventWebSocketStreamStopped, + { once: true }, + ); + stream.addEventListener(EventAll.name, this.handleEventWebSocketStream); + await stream.start(); + // Ok the stream is opened and working + if (this.type === 'client') { + this.streamIdClientBidi = (this.streamIdClientBidi + 2n) as StreamId; + } else if (this.type === 'server') { + this.streamIdServerBidi = (this.streamIdServerBidi + 2n) as StreamId; + } + return stream; + }); + } + + /** + * Send data on the WebSocket + */ + private async send(data: Uint8Array | Array) { + if (this.socket.readyState !== ws.OPEN) { + this.logger.debug('a message was dropped because the socket is not open'); + return; + } + + let array: Uint8Array; + if (ArrayBuffer.isView(data)) { + array = data; + } else { + array = concatUInt8Array(...data); + } + try { + const sendProm = utils.promise(); + this.socket.send(array, { binary: true }, (err) => { + if (err == null) sendProm.resolveP(); + else sendProm.rejectP(err); + }); + await sendProm.p; + } catch (err) { + const errorCode = utils.ConnectionErrorCode.InternalServerError; + const reason = + 'Connection was unable to send data due to internal WebSocket error'; + this.closeSocket(errorCode, reason); + const e_ = new errors.ErrorWebSocketConnectionLocal(reason, { + cause: new errors.ErrorWebSocketServerInternal(reason, { + cause: err, + }), + data: { + errorCode, + reason, + }, + }); + this.dispatchEvent( + new events.EventWebSocketConnectionError({ + detail: e_, + }), + ); + // Will not wait for close, happens asynchronously + } + } + + /** + * Stops WebSocketConnection + * @param opts + * @param opts.errorCode - The error code to send to the peer on closing + * @param opts.errorMessage - The error message to send to the peer on closing + * @param opts.force - When force is false, the returned promise will wait for all streams to close naturally before resolving. + */ + public async stop({ + errorCode = utils.ConnectionErrorCode.Normal, + reason = '', + force = true, + }: { + errorCode?: number; + reason?: string; + force?: boolean; + } = {}) { + this.logger.info(`Stop ${this.constructor.name}`); + this.stopKeepAliveIntervalTimer(); + this.stopKeepAliveTimeoutTimer(); + if ( + this.socket.readyState !== ws.CLOSING && + this.socket.readyState !== ws.CLOSED + ) { + this.closeSocket(errorCode, reason); + const e = new errors.ErrorWebSocketConnectionLocal( + `Locally closed with code ${errorCode}`, + { + data: { + errorCode, + reason, + }, + }, + ); + this.dispatchEvent( + new events.EventWebSocketConnectionError({ detail: e }), + ); + } + + // Cleaning up existing streams + const streamsDestroyP: Array> = []; + this.logger.debug('triggering stream destruction'); + for (const stream of this.streamMap.values()) { + streamsDestroyP.push( + stream.stop({ + reason: this.errorLast, + force: + force || + this.socket.readyState === ws.CLOSED || + this.socket.readyState === ws.CLOSING, + }), + ); + } + await Promise.all(streamsDestroyP); + // Waiting for `closedP` to resolve + await this.closedP; + // Remove event listeners before possible event dispatching to avoid recursion + this.removeEventListener( + events.EventWebSocketConnectionError.name, + this.handleEventWebSocketConnectionError, + ); + this.removeEventListener( + events.EventWebSocketConnectionClose.name, + this.handleEventWebSocketConnectionClose, + ); + this.socket.off('message', this.handleSocketMessage); + this.socket.off('ping', this.handleSocketPing); + this.socket.off('pong', this.handleSocketPong); + this.socket.off('error', this.handleSocketError); + + this.logger.info(`Stopped ${this.constructor.name}`); + } + + protected setKeepAliveTimeoutTimer(): void { + const logger = this.logger.getChild('timer'); + const timeout = this.config.keepAliveTimeoutTime; + const keepAliveTimeOutHandler = async (signal: AbortSignal) => { + if (signal.aborted) return; + if (this.socket.readyState === ws.CLOSED) { + this.resolveClosedP(); + return; + } + this.dispatchEvent( + new events.EventWebSocketConnectionError({ + detail: new errors.ErrorWebSocketConnectionKeepAliveTimeOut(), + }), + ); + }; + // If there was an existing timer, we cancel it and set a new one + if ( + this.keepAliveTimeOutTimer != null && + this.keepAliveTimeOutTimer.status === null + ) { + // Logger.debug(`resetting timer with ${timeout} delay`); + this.keepAliveTimeOutTimer.reset(timeout); + } else { + logger.debug(`timeout created with delay ${timeout}`); + this.keepAliveTimeOutTimer?.cancel(); + this.keepAliveTimeOutTimer = new Timer({ + delay: timeout, + handler: keepAliveTimeOutHandler, + }); + } + } + + /** + * Stops the keep alive interval timer + */ + protected stopKeepAliveTimeoutTimer(): void { + this.keepAliveTimeOutTimer?.cancel(); + } + + protected startKeepAliveIntervalTimer(ms: number): void { + const keepAliveHandler = async () => { + this.socket.ping(); + this.keepAliveIntervalTimer = new Timer({ + delay: ms, + handler: keepAliveHandler, + }); + }; + this.keepAliveIntervalTimer = new Timer({ + delay: ms, + handler: keepAliveHandler, + }); + } + /** + * Stops the keep alive interval timer + */ + protected stopKeepAliveIntervalTimer(): void { + this.keepAliveIntervalTimer?.cancel(); + } } export default WebSocketConnection; diff --git a/src/WebSocketConnectionMap.ts b/src/WebSocketConnectionMap.ts new file mode 100644 index 00000000..43cc2ae6 --- /dev/null +++ b/src/WebSocketConnectionMap.ts @@ -0,0 +1,27 @@ +import type WebSocketConnection from './WebSocketConnection'; +import Counter from 'resource-counter'; + +class WebSocketConnectionMap extends Map { + protected counter: Counter; + public constructor() { + super(); + this.counter = new Counter(0); + } + public allocateId(): number { + return this.counter.allocate(); + } + public add(conn: WebSocketConnection): this { + const key = conn.connectionId; + return this.set(key, conn); + } + public delete(key: number): boolean { + this.counter.deallocate(key); + return super.delete(key); + } + public clear(): void { + this.counter = new Counter(0); + return super.clear(); + } +} + +export default WebSocketConnectionMap; diff --git a/src/WebSocketServer.ts b/src/WebSocketServer.ts index a43d93e5..2c1cf340 100644 --- a/src/WebSocketServer.ts +++ b/src/WebSocketServer.ts @@ -1,5 +1,514 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import type tls from 'tls'; +import type { + Host, + Port, + ResolveHostname, + StreamCodeToReason, + StreamReasonToCode, + WebSocketConfig, + WebSocketServerConfigInput, + WebSocketServerConfigInputWithInjectedServer, +} from './types'; +import type { EventAll } from '@matrixai/events'; +import type { TLSSocket } from 'tls'; +import https from 'https'; +import { AbstractEvent } from '@matrixai/events'; +import { StartStop, running, ready } from '@matrixai/async-init/dist/StartStop'; +import Logger from '@matrixai/logger'; +import * as ws from 'ws'; +import * as errors from './errors'; +import * as events from './events'; +import * as utils from './utils'; +import WebSocketConnection from './WebSocketConnection'; +import { serverDefault } from './config'; +import WebSocketConnectionMap from './WebSocketConnectionMap'; + +interface WebSocketServer extends StartStop {} +/** + * You must provide an error handler `addEventListener('error')`. + * Otherwise, errors will just be ignored. + * + * Events: + * - {@link events.EventWebSocketServerStart}, + * - {@link events.EventWebSocketServerStarted}, + * - {@link events.EventWebSocketServerStop}, + * - {@link events.EventWebSocketServerStopped}, + * - {@link events.EventWebSocketServerConnection} + * - {@link events.EventWebSocketServerError} + */ +@StartStop({ + eventStart: events.EventWebSocketServerStart, + eventStarted: events.EventWebSocketServerStarted, + eventStop: events.EventWebSocketServerStop, + eventStopped: events.EventWebSocketServerStopped, +}) class WebSocketServer { + /** + * Determines whether the socket is injected or not + */ + public readonly isServerShared: boolean; + + /** + * Custom reason to code converter for new connections. + */ + public reasonToCode?: StreamReasonToCode; + /** + * Custom code to reason converted for new connections. + */ + public codeToReason?: StreamCodeToReason; + + protected logger: Logger; + /** + * Configuration for new connections. + */ + protected config: WebSocketConfig; + protected resolveHostname: ResolveHostname; + /** + * Connection timeout for new connections. + */ + public connectTimeoutTime?: number; + + /** + * Map of connections with connectionId keys that correspond to WebSocketConnection values. + */ + public readonly connectionMap: WebSocketConnectionMap = + new WebSocketConnectionMap(); + protected server: https.Server; + protected webSocketServer: ws.WebSocketServer; + protected webSocketServerClosed = false; + + protected _closed: boolean = false; + /** + * Resolved when the underlying server is closed. + */ + public readonly closedP: Promise; + protected resolveClosedP: () => void; + + protected _port: number; + protected _host: string; + + /** + * This must be attached once. + */ + protected handleEventWebSocketServerError = async ( + evt: events.EventWebSocketServerError, + ) => { + const error = evt.detail; + this.logger.error(utils.formatError(error)); + }; + + protected handleEventWebSocketServerClose = async () => { + // Close means we are "closing", but error state has occurred + // Not that we have actually closed + // That's different from socket close event which means "fully" closed + // We would call that `Closed` event, not `Close` event + this.webSocketServer.off('close', this.handleWebSocketServerClosed); + this.server.off('close', this.handleServerClosed); + + if (this.isServerShared) { + if (this.webSocketServerClosed) { + this.resolveClosedP(); + } + this.webSocketServer.close(() => this.resolveClosedP()); + await this.closedP; + } else { + if (!this.webSocketServerClosed) { + const wsClosedP = utils.promise(); + this.webSocketServer.close(() => wsClosedP.resolveP()); + await wsClosedP.p; + } + if (!this.server.listening) { + this.resolveClosedP(); + } + this.server.close(() => this.resolveClosedP()); + await this.closedP; + } + + this._closed = true; + if (this[running]) { + // If stop fails, it is a software bug + await this.stop({ force: true }); + } + }; + + /** + * This must be attached once. + */ + protected handleEventWebSocketConnectionStopped = ( + evt: events.EventWebSocketConnectionStopped, + ) => { + const WebSocketConnection = evt.target as WebSocketConnection; + this.connectionMap.delete(WebSocketConnection.connectionId); + }; + + protected handleEventWebSocketConnection = (evt: EventAll) => { + if (evt.detail instanceof AbstractEvent) { + this.dispatchEvent(evt.detail.clone()); + } + }; + + /** + * Used to trigger stopping if the underlying server fails + */ + protected handleServerClosed = async () => { + this.dispatchEvent(new events.EventWebSocketServerClose()); + }; + + protected handleWebSocketServerClosed = async () => { + this.webSocketServerClosed = true; + this.dispatchEvent(new events.EventWebSocketServerClose()); + }; + + /** + * Used to propagate error conditions + */ + protected handleServerError = (e: Error) => { + this.dispatchEvent( + new events.EventWebSocketServerError({ + detail: new errors.ErrorWebSocketServerInternal( + 'An error occured on the underlying server', + { + cause: e, + }, + ), + }), + ); + this.dispatchEvent(new events.EventWebSocketServerClose()); + }; + + /** + * Handles the creation of the `ReadableWritablePair` and provides it to the + * StreamPair handler. + */ + protected handleServerConnection = async ( + webSocket: ws.WebSocket, + request: IncomingMessage, + ) => { + const httpSocket = request.connection; + const connectionId = this.connectionMap.allocateId(); + const peerCert = (httpSocket as TLSSocket).getPeerCertificate(true); + const peerCertChain = utils.toPeerCertChain(peerCert); + const localCACertsChain = utils + .collectPEMs(this.config.ca) + .map(utils.pemToDER); + const localCertsChain = utils + .collectPEMs(this.config.cert) + .map(utils.pemToDER); + const connection = new WebSocketConnection({ + type: 'server', + connectionId: connectionId, + meta: { + remoteHost: httpSocket.remoteAddress ?? '', + remotePort: httpSocket.remotePort ?? 0, + localHost: httpSocket.localAddress ?? '', + localPort: httpSocket.localPort ?? 0, + localCACertsChain, + localCertsChain, + remoteCertsChain: peerCertChain, + }, + socket: webSocket, + config: { ...this.config }, + reasonToCode: this.reasonToCode, + codeToReason: this.codeToReason, + logger: this.logger.getChild( + `${WebSocketConnection.name} ${connectionId}`, + ), + }); + this.connectionMap.add(connection); + connection.addEventListener( + events.EventWebSocketConnectionStopped.name, + this.handleEventWebSocketConnectionStopped, + ); + try { + await connection.start({ + timer: this.connectTimeoutTime, + }); + } catch (e) { + connection.removeEventListener( + events.EventWebSocketConnectionStopped.name, + this.handleEventWebSocketConnectionStopped, + ); + this.connectionMap.delete(connection.connectionId); + this.dispatchEvent( + new events.EventWebSocketServerError({ + detail: e, + }), + ); + } + this.dispatchEvent( + new events.EventWebSocketServerConnection({ + detail: connection, + }), + ); + }; + + /** + * WebSocketServer.constructor + * + * - if `opts.server` is not provided, `.start` will create a new `https` server. + * - if `opts.server` is provided and not already listening, `.start` make the server start listening and use the provided server. + * - if `opts.server` is provided and already listening, `.start` use the provided server. + * - if `opts.server` is provided, `verifyCallback` and `verifyPeer` must be `undefined`, and the TLS verification policy will follow that of the underlying server. + * + * @param opts + * @param opts.config - configuration for new connections. + * @param opts.server - if not provided, a new server will be created. + * @param opts.reasonToCode - reasonToCode for stream errors + * @param opts.codeToReason - codeToReason for stream errors + * @param opts.logger - default logger is used if not provided + */ + constructor({ + config, + resolveHostname = utils.resolveHostname, + server, + reasonToCode, + codeToReason, + connectTimeoutTime, + logger, + }: + | { + config: WebSocketServerConfigInput; + resolveHostname?: ResolveHostname; + server?: undefined; + reasonToCode?: StreamReasonToCode; + codeToReason?: StreamCodeToReason; + connectTimeoutTime?: number; + logger?: Logger; + } + | { + config?: WebSocketServerConfigInputWithInjectedServer; + resolveHostname?: ResolveHostname; + server: https.Server; + reasonToCode?: StreamReasonToCode; + codeToReason?: StreamCodeToReason; + connectTimeoutTime?: number; + logger?: Logger; + }) { + this.logger = logger ?? new Logger(this.constructor.name); + this.config = { + ...serverDefault, + ...config, + }; + this.resolveHostname = resolveHostname; + + this.connectTimeoutTime = connectTimeoutTime; + + this.reasonToCode = reasonToCode; + this.codeToReason = codeToReason; + const { p: closedP, resolveP: resolveClosedP } = utils.promise(); + this.closedP = closedP; + this.resolveClosedP = resolveClosedP; + + if (server != null) { + this.isServerShared = true; + this.server = server; + } else { + this.isServerShared = false; + } + } + + @ready(new errors.ErrorWebSocketServerNotRunning()) + public get host(): Host { + return (this.server.address() as any)?.address ?? ('' as Host); + } + + @ready(new errors.ErrorWebSocketServerNotRunning()) + public get port(): Port { + return (this.server.address() as any)?.port ?? (0 as Port); + } + + /** + * Boolean that indicates whether the internal server is closed or not. + */ + public get closed() { + return this._closed; + } + + /** + * Starts the WebSocketServer. + * + * If the server is shared and it is not listening, it will be started. + * If the server is not shared, a server will be created and started. + * + * @param opts + * @param opts.host - host to listen on, defaults to '::' + * @param opts.port - port to listen on, defaults to 0 + * @param opts.path - the path the WebSocketServer should respond to upgrade requests on + * @param opts.ipv6Only - ipv6 only, defaults to false + */ + public async start({ + host = '::', + port = 0, + path, + ipv6Only = false, + }: { + host?: string; + port?: number; + path?: string; + ipv6Only?: boolean; + } = {}): Promise { + this.logger.info(`Starting ${this.constructor.name}`); + const [host_] = await utils.resolveHost(host, this.resolveHostname); + const port_ = utils.toPort(port); + if (!this.isServerShared) { + this.server = https.createServer({ + rejectUnauthorized: + this.config.verifyPeer && this.config.verifyCallback == null, + requestCert: true, + key: this.config.key as any, + cert: this.config.cert as any, + ca: this.config.ca as any, + }); + } + this.webSocketServer = new ws.WebSocketServer({ + server: this.server, + path, + verifyClient: async (info, done) => { + // Since this will only be done before the opening of a WebSocketConnection, there is no need to worry about the CA deviating from the WebSocketConnection's config. + if (this.config.verifyPeer && this.config.verifyCallback != null) { + const peerCert = (info.req.socket as TLSSocket).getPeerCertificate( + true, + ); + const peerCertChain = utils.toPeerCertChain(peerCert); + const ca = utils.collectPEMs(this.config.ca).map(utils.pemToDER); + try { + await this.config.verifyCallback(peerCertChain, ca); + return done(true); + } catch (e) { + info.req.destroy(e); + return; + } + } + done(true); + }, + }); + + this.webSocketServer.on('connection', this.handleServerConnection); + this.webSocketServer.on('close', this.handleWebSocketServerClosed); + this.server.on('close', this.handleServerClosed); + this.webSocketServer.on('error', this.handleServerError); + this.server.on('error', this.handleServerError); + this.server.on('request', this.handleServerRequest); + + if (!this.server.listening) { + const listenProm = utils.promise(); + this.server.listen( + { + host: host_, + port: port_, + ipv6Only, + }, + listenProm.resolveP, + ); + await listenProm.p; + } + + this.addEventListener( + events.EventWebSocketServerError.name, + this.handleEventWebSocketServerError, + ); + this.addEventListener( + events.EventWebSocketServerClose.name, + this.handleEventWebSocketServerClose, + { once: true }, + ); + + const serverAddress = this.server.address(); + if (serverAddress == null || typeof serverAddress === 'string') { + utils.never(); + } + this._port = serverAddress.port; + this._host = serverAddress.address ?? '127.0.0.1'; + this.logger.info(`Started ${this.constructor.name}`); + } + + /** + * Stops WebSocketServer + * @param opts + * @param opts.errorCode - The error code to send to connections on closing + * @param opts.errorMessage - The error message to send to connections on closing + * @param opts.force - When force is false, the returned promise will wait for all streams and connections to close naturally before resolving. + */ + public async stop({ + errorCode = utils.ConnectionErrorCode.Normal, + errorMessage = '', + force = true, + }: { + errorCode?: number; + errorMessage?: string; + force?: boolean; + } = {}): Promise { + this.logger.info(`Stopping ${this.constructor.name}`); + const destroyProms: Array> = []; + for (const webSocketConnection of this.connectionMap.values()) { + destroyProms.push( + webSocketConnection.stop({ + errorCode, + reason: errorMessage, + force, + }), + ); + } + this.logger.debug('Awaiting connections to destroy'); + await Promise.all(destroyProms); + this.logger.debug('All connections destroyed'); + // Close the server by closing the underlying WebSocketServer + if (!this._closed) { + // If this succeeds, then we are just transitioned to close + // This will trigger noop recursion, that's fine + this.dispatchEvent(new events.EventWebSocketServerClose()); + } + await this.closedP; + + this.removeEventListener( + events.EventWebSocketServerError.name, + this.handleEventWebSocketServerError, + ); + this.removeEventListener( + events.EventWebSocketServerClose.name, + this.handleEventWebSocketServerClose, + ); + + this.webSocketServer.off('connection', this.handleServerConnection); + this.webSocketServer.off('close', this.handleServerClosed); + this.server.off('close', this.handleServerClosed); + this.webSocketServer.off('error', this.handleServerError); + this.server.off('error', this.handleServerError); + this.server.on('request', this.handleServerRequest); + this.logger.info(`Stopped ${this.constructor.name}`); + } + + /** + * Updates the server config. + * Existing connections will not be affected. + */ + @ready(new errors.ErrorWebSocketServerNotRunning()) + public updateConfig(config: WebSocketServerConfigInput): void { + const tlsServer = this.server as tls.Server; + const wsConfig = { + ...this.config, + ...config, + }; + tlsServer.setSecureContext({ + key: wsConfig.key as any, + cert: wsConfig.cert as any, + ca: wsConfig.ca as any, + }); + this.config = wsConfig; + } + /** + * Will tell any normal HTTP request to upgrade + */ + protected handleServerRequest = (_req, res: ServerResponse) => { + res + .writeHead(426, '426 Upgrade Required', { + connection: 'Upgrade', + upgrade: 'websocket', + }) + .end('426 Upgrade Required'); + }; } export default WebSocketServer; diff --git a/src/WebSocketStream.ts b/src/WebSocketStream.ts index 31a4066f..cf448e80 100644 --- a/src/WebSocketStream.ts +++ b/src/WebSocketStream.ts @@ -1,5 +1,704 @@ -class WebSocketStream { +import type { + ConnectionMetadata, + StreamCodeToReason, + StreamReasonToCode, +} from './types'; +import type WebSocketConnection from './WebSocketConnection'; +import type { StreamId, StreamMessage, VarInt } from './message'; +import type { + ReadableWritablePair, + WritableStreamDefaultController, + ReadableStreamDefaultController, +} from 'stream/web'; +import { + ReadableStream, + WritableStream, + CountQueuingStrategy, +} from 'stream/web'; +import { + StartStop, + ready, + running, + status, +} from '@matrixai/async-init/dist/StartStop'; +import Logger from '@matrixai/logger'; +import { generateStreamId } from './message'; +import * as utils from './utils'; +import * as errors from './errors'; +import * as events from './events'; +import { + generateStreamMessage, + parseStreamMessage, + StreamErrorCode, + StreamMessageType, + StreamShutdown, +} from './message'; +import WebSocketStreamQueue from './WebSocketStreamQueue'; +interface WebSocketStream extends StartStop {} +/** + * Events: + * - {@link events.EventWebSocketStreamStart} + * - {@link events.EventWebSocketStreamStarted} + * - {@link events.EventWebSocketStreamStop} + * - {@link events.EventWebSocketStreamStopped} + * - {@link events.EventWebSocketStreamError} + * - {@link events.EventWebSocketStreamCloseRead} + * - {@link events.EventWebSocketStreamCloseWrite} + * - {@link events.EventWebSocketStreamSend} + */ +@StartStop({ + eventStart: events.EventWebSocketStreamStart, + eventStarted: events.EventWebSocketStreamStarted, + eventStop: events.EventWebSocketStreamStop, + eventStopped: events.EventWebSocketStreamStopped, +}) +class WebSocketStream implements ReadableWritablePair { + public async start(): Promise { + this.logger.info(`Start ${this.constructor.name}`); + this.addEventListener( + events.EventWebSocketStreamError.name, + this.handleEventWebSocketStreamError, + ); + this.addEventListener( + events.EventWebSocketStreamCloseRead.name, + this.handleEventWebSocketStreamCloseRead, + { once: true }, + ); + this.addEventListener( + events.EventWebSocketStreamCloseWrite.name, + this.handleEventWebSocketStreamCloseWrite, + { once: true }, + ); + this.logger.info(`Started ${this.constructor.name}`); + this.streamSend({ + type: StreamMessageType.Ack, + payload: this.readableQueueBufferSize, + }); + } + + public readonly initiated: 'local' | 'peer'; + + public readonly streamId: StreamId; + public readonly encodedStreamId: Uint8Array; + /** + * Errors: + * - {@link errors.ErrorWebSocketStreamClose} - This will happen if the stream is closed with {@link WebSocketStream.stop} or if the {@link WebSocketConnection} was closed. + * - {@link errors.ErrorWebSocketStreamCancel} - This will happen if the stream is closed with {@link WebSocketStream.cancel} + * - {@link errors.ErrorWebSocketStreamUnknown} - Unknown error + * - {@link errors.ErrorWebSocketStreamReadableBufferOverload} - This will happen when the readable buffer is overloaded + * - {@link errors.ErrorWebSocketStreamReadableParse} - This will happen when the ReadableStream cannot parse an incoming message + * - any errors from `.cancel(reason)` + */ + public readonly readable: ReadableStream; + /** + * Errors: + * - {@link errors.ErrorWebSocketStreamClose} - This will happen if the stream is closed with {@link WebSocketStream.stop} or if the {@link WebSocketConnection} was closed. + * - {@link errors.ErrorWebSocketStreamCancel} - This will happen if the stream is closed with {@link WebSocketStream.cancel} + * - {@link errors.ErrorWebSocketStreamUnknown} - Unknown error + * - {@link errors.ErrorWebSocketStreamReadableBufferOverload} - This will happen when the receiving ReadableStream's buffer is overloaded + * - {@link errors.ErrorWebSocketStreamReadableParse} - This will happen when the receiving ReadableStream cannot parse a sent message + * - any errors from `.cancel(reason)` or `.abort(reason)` + */ + public readonly writable: WritableStream; + + protected logger: Logger; + protected connection: WebSocketConnection; + protected reasonToCode: StreamReasonToCode; + protected codeToReason: StreamCodeToReason; + protected readableController: ReadableStreamDefaultController; + protected writableController: WritableStreamDefaultController; + + protected _readClosed = false; + protected _writeClosed = false; + + protected readableQueue: WebSocketStreamQueue = new WebSocketStreamQueue(); + protected readableQueueBufferSize = 0; + protected writableDesiredSize = 0; + protected resolveReadableP?: () => void; + protected rejectReadableP?: (reason?: any) => void; + protected resolveWritableP?: () => void; + protected rejectWritableP?: (reason?: any) => void; + + /** + * Resolved when the stream has been completely closed (both readable and writable sides). + */ + public readonly closedP: Promise; + protected resolveClosedP: () => void; + + /** + * We expect WebSocket stream error in 2 ways. + * WebSocket stream closure of the stream codes. + * On read side + * On write side + * We are able to use exception classes to distinguish things + * Because it's always about the error itself!A + * Note that you must distinguish between actual internal errors, and errors on the stream itself + */ + protected handleEventWebSocketStreamError = async ( + evt: events.EventWebSocketStreamError, + ) => { + const error = evt.detail; + this.logger.error(utils.formatError(error)); + if (error instanceof errors.ErrorWebSocketStreamInternal) { + throw error; + } + if ( + error instanceof errors.ErrorWebSocketStreamLocalRead || + error instanceof errors.ErrorWebSocketStreamPeerRead + ) { + this.dispatchEvent( + new events.EventWebSocketStreamCloseRead({ + detail: error, + }), + ); + } else if ( + error instanceof errors.ErrorWebSocketStreamLocalWrite || + error instanceof errors.ErrorWebSocketStreamPeerWrite + ) { + this.dispatchEvent( + new events.EventWebSocketStreamCloseWrite({ + detail: error, + }), + ); + } + }; + + protected handleEventWebSocketStreamCloseRead = async () => { + this._readClosed = true; + if (this._readClosed && this._writeClosed) { + this.resolveClosedP(); + if (this[running] && this[status] !== 'stopping') { + await this.stop({ force: false }); + } + } + }; + + protected handleEventWebSocketStreamCloseWrite = async () => { + this._writeClosed = true; + if (this._readClosed && this._writeClosed) { + this.resolveClosedP(); + if (this[running] && this[status] !== 'stopping') { + // If we are destroying, we still end up calling this + // This is to enable, that when a failed cancellation to continue to destroy + // By disabling force, we don't end up running cancel again + // But that way it does infact successfully destroy + await this.stop({ force: false }); + } + } + }; + + constructor({ + initiated, + streamId, + connection, + bufferSize, + reasonToCode = () => 0n, + codeToReason = (type, code) => + new Error(`${type.toString()} ${code.toString()}`), + logger, + }: { + initiated: 'local' | 'peer'; + streamId: StreamId; + connection: WebSocketConnection; + bufferSize: number; + reasonToCode?: StreamReasonToCode; + codeToReason?: StreamCodeToReason; + logger?: Logger; + }) { + this.logger = logger ?? new Logger(`${this.constructor.name}`); + this.initiated = initiated; + this.streamId = streamId; + this.encodedStreamId = generateStreamId(streamId); + this.connection = connection; + this.reasonToCode = reasonToCode; + this.codeToReason = codeToReason; + // This will be used to know when both readable and writable is closed + const { p: closedP, resolveP: resolveClosedP } = utils.promise(); + this.closedP = closedP; + this.resolveClosedP = resolveClosedP; + + this.readableQueueBufferSize = bufferSize; + + this.readable = new ReadableStream( + { + start: this.readableStart.bind(this), + pull: this.readablePull.bind(this), + cancel: this.readableCancel.bind(this), + }, + new CountQueuingStrategy({ + highWaterMark: 1, + }), + ); + + this.writable = new WritableStream( + { + start: this.writableStart.bind(this), + write: this.writableWrite.bind(this), + close: this.writableClose.bind(this), + abort: this.writableAbort.bind(this), + }, + { + highWaterMark: 1, + }, + ); + } + + /** + * Returns connection data for the connection this stream is on. + */ + @ready(new errors.ErrorWebSocketStreamDestroyed()) + public get meta(): ConnectionMetadata { + return this.connection.meta(); + } + + /** + * Returns true if the writable has closed. + */ + public get writeClosed(): boolean { + return this._writeClosed; + } + + /** + * Returns true if the readable has closed. + */ + public get readClosed(): boolean { + return this._readClosed; + } + + /** + * Whether the stream has been completely closed (both readable and writable sides). + */ + public get closed() { + return this._readClosed && this._writeClosed; + } + + /** + * This method can be arrived top-down or bottom-up: + * + * 1. Top-down control flow - means explicit destruction from WebSocketConnection + * 2. Bottom-up control flow - means stream events from users of this stream + * + * If force is true then this will cancel readable and abort writable. + * If force is false then it will just wait for readable and writable to be closed. + * + * Unlike WebSocketConnection, this defaults to true for force. + */ + public async stop({ + force = true, + reason, + }: { + force?: boolean; + reason?: any; + } = {}) { + this.logger.info(`Destroy ${this.constructor.name}`); + if (force && !(this._readClosed && this._writeClosed)) { + // If force is true, we are going to cancel the 2 streams + // This means cancelling the readable stream and aborting the writable stream + // Whether this fails or succeeds, it will trigger the close handler + // Which means we recurse back into `this.destroy`. + // If it failed, it recurses into destroy and will succeed (because the force will be false) + // If it succeeded, it recurses into destroy into a noop. + this.cancel(reason); + } + await this.closedP; + this.removeEventListener( + events.EventWebSocketStreamError.name, + this.handleEventWebSocketStreamError, + ); + this.removeEventListener( + events.EventWebSocketStreamCloseRead.name, + this.handleEventWebSocketStreamCloseRead, + ); + this.removeEventListener( + events.EventWebSocketStreamCloseWrite.name, + this.handleEventWebSocketStreamCloseWrite, + ); + this.logger.info(`Destroyed ${this.constructor.name}`); + } + + /** + * Will trigger the destruction of the `WebSocketStream` if the readable or writable haven't closed, yet they will be forced + * closed with `reason` as the error. + * If streams have already closed then this will do nothing. + * This is synchronous by design but cancelling will happen asynchronously in the background. + * + * This ends up calling the cancel and abort methods. + * Those methods are needed because the readable and writable might be locked with + * a reader and writer respectively. So we have to cancel and abort from the "inside" of + * the stream. + * It's essential that this is synchronous, as that ensures only one thing is running at a time. + * Note that if cancellation fails... + * + * Calling this will lead an asynchronous destruction of this `WebSocketStream` instance. + * This could throw actually. But cancellation is likely to have occurred. + */ + public cancel(reason?: any) { + this.readableCancel(reason); + this.writableAbort(reason); + } + + protected readableStart(controller: ReadableStreamDefaultController): void { + this.readableController = controller; + } + + protected writableStart(controller: WritableStreamDefaultController): void { + this.writableController = controller; + } + + protected async readablePull( + controller: ReadableStreamDefaultController, + ): Promise { + // If a readable has ended, whether by the closing of the sender's WritableStream or by calling `.close`, do not bother to send back an ACK + if (this._readClosed) { + return; + } + + const { + p: readableP, + resolveP: resolveReadableP, + rejectP: rejectReadableP, + } = utils.promise(); + + this.resolveReadableP = resolveReadableP; + this.rejectReadableP = rejectReadableP; + + // Resolve the promise immediately if there are messages + // If not, wait for there to be to messages + if (this.readableQueue.count > 0) { + resolveReadableP(); + } + await readableP; + + // Data will be null in the case of stream destruction before the readable buffer is blocked + // we're going to just enqueue an empty buffer in case it is null for some other reason, so that the next read is able to complete + const data = this.readableQueue.dequeue(); + if (data == null) { + controller.enqueue([]); + return; + } + const readBytes = data.length; + controller.enqueue(data); + + this.logger.debug(`${readBytes} bytes have been pushed onto stream buffer`); + + this.streamSend({ + type: StreamMessageType.Ack, + payload: readBytes, + }); + } + + protected async writableWrite(chunk: Uint8Array): Promise { + while (chunk.length > 0) { + // Do not bother to write or wait for ACK if the writable has ended + if (this._writeClosed) { + return; + } + this.logger.debug( + `${chunk.length} bytes need to be written into a receiver buffer of ${this.writableDesiredSize} bytes`, + ); + + const { + p: writableP, + resolveP: resolveWritableP, + rejectP: rejectWritableP, + } = utils.promise(); + + this.resolveWritableP = resolveWritableP; + this.rejectWritableP = rejectWritableP; + + // Resolve the promise immediately if there is available space to be written + // If not, wait for the writableDesiredSize to be updated by the peer + if (this.writableDesiredSize > 0) { + resolveWritableP(); + } + await writableP; + + // Chunking + // .subarray parameters begin and end are clamped to the size of the Uint8Array + const data = chunk.subarray(0, this.writableDesiredSize); + if (chunk.length > this.writableDesiredSize) { + this.logger.debug( + `this chunk will be split into sizes of ${this.writableDesiredSize} bytes`, + ); + } + + const bytesWritten = data.length; + // Decrement the desired size by the amount of bytes written + this.writableDesiredSize -= bytesWritten; + this.logger.debug( + `writableDesiredSize is now ${this.writableDesiredSize} due to write`, + ); + this.streamSend({ + type: StreamMessageType.Data, + payload: data, + }); + chunk = chunk.subarray(bytesWritten); + } + } + + /** + * This is mutually exclusive with write. + * It will be serialised! + */ + protected writableClose(): void { + // Graceful close on the write without any code + this.dispatchEvent(new events.EventWebSocketStreamCloseWrite()); + this.streamSend({ + type: StreamMessageType.Close, + payload: StreamShutdown.Read, + }); + } + + /** + * This is factored out and callable by both `readable.cancel` and `this.cancel`. + * ReadableStream ensures that this method is idempotent + * + * @throws {errors.ErrorWebSocketStreamInternal} + */ + protected readableCancel(reason?: any): void { + // Ignore if already closed + // This is only needed if this function is called from `this.cancel`. + // Because the web stream already ensures `cancel` is idempotent. + if (this._readClosed) return; + const code = this.reasonToCode('read', reason) as VarInt; + const e = new errors.ErrorWebSocketStreamLocalRead( + 'Closing readable stream locally', + { + data: { code }, + cause: reason, + }, + ); + // This is idempotent and won't error even if it is already stopped + this.readableController.error(reason); + // This rejects the readableP if it exists + // The pull method may be blocked by `await readableP` + // When rejected, it will throw up the exception + // However because the stream is cancelled, then + // the exception has no effect, and any reads of this stream + // will simply return `{ value: undefined, done: true }` + this.rejectReadableP?.(reason); + this.dispatchEvent( + new events.EventWebSocketStreamError({ + detail: e, + }), + ); + this.streamSend({ + type: StreamMessageType.Error, + payload: { + shutdown: StreamShutdown.Write, + code, + }, + }); + return; + } + + /** + * This is factored out and callable by both `writable.abort` and `this.cancel`. + */ + protected writableAbort(reason?: any): void { + // Ignore if already closed + // This is only needed if this function is called from `this.cancel`. + // Because the web stream already ensures `cancel` is idempotent. + if (this._writeClosed) return; + const code = this.reasonToCode('write', reason) as VarInt; + const e = new errors.ErrorWebSocketStreamLocalWrite( + 'Closing writable stream locally', + { + data: { code }, + cause: reason, + }, + ); + this.writableController.error(reason); + // This will reject the writable call + // But at the same time, it means the writable stream transitions to errored state + // But the whole writable stream is going to be closed anyway + this.rejectWritableP?.(reason); + this.dispatchEvent( + new events.EventWebSocketStreamError({ + detail: e, + }), + ); + this.streamSend({ + type: StreamMessageType.Error, + payload: { + shutdown: StreamShutdown.Read, + code, + }, + }); + return; + } + + protected streamSend(message: StreamMessage) { + const array = [this.encodedStreamId]; + array.push(...generateStreamMessage(message, false)); + const evt = new events.EventWebSocketStreamSend(); + evt.msg = array; + this.dispatchEvent(evt); + } + + /** + * Put a message frame into a stream. + * This will not will not error out, but will rather close the ReadableStream assuming any further reads are expected to fail. + * @param message - The message to put into the stream. + * @internal + */ + public async streamRecv(message: Uint8Array) { + if (message.length === 0) { + this.logger.debug(`received empty message, closing stream`); + this.readableCancel( + new errors.ErrorWebSocketStreamReadableParse('empty message', { + cause: new RangeError(), + }), + ); + return; + } + + let parsedMessage: StreamMessage; + try { + parsedMessage = parseStreamMessage(message); + } catch (err) { + this.readableCancel( + new errors.ErrorWebSocketStreamReadableParse(err.message, { + cause: err, + }), + ); + return; + } + + if (parsedMessage.type === StreamMessageType.Ack) { + this.writableDesiredSize += parsedMessage.payload; + this.resolveWritableP?.(); + this.logger.debug( + `writableDesiredSize is now ${this.writableDesiredSize} due to ACK`, + ); + } else if (parsedMessage.type === StreamMessageType.Data) { + if (this._readClosed) { + return; + } + if ( + parsedMessage.payload.length > + this.readableQueueBufferSize - this.readableQueue.length + ) { + this.readableCancel( + new errors.ErrorWebSocketStreamReadableBufferOverload(), + ); + return; + } + this.readableQueue.queue(parsedMessage.payload); + this.resolveReadableP?.(); + } else if (parsedMessage.type === StreamMessageType.Error) { + const { shutdown, code } = parsedMessage.payload; + let reason: any; + switch (code) { + case StreamErrorCode.Unknown: + reason = new errors.ErrorWebSocketStreamUnknown( + 'receiver encountered an unknown error', + ); + break; + case StreamErrorCode.ErrorReadableStreamParse: + reason = new errors.ErrorWebSocketStreamReadableParse( + 'receiver was unable to parse a sent message', + ); + break; + case StreamErrorCode.ErrorReadableStreamBufferOverflow: + reason = new errors.ErrorWebSocketStreamReadableBufferOverload( + 'receiver was unable to accept a sent message', + ); + break; + default: + reason = await this.codeToReason('read', code); + } + if (shutdown === StreamShutdown.Read) { + if (this._readClosed) return; + const code = this.reasonToCode('read', reason) as VarInt; + const e = new errors.ErrorWebSocketStreamLocalRead( + 'Closing readable stream due to Error message from peer', + { + data: { code }, + cause: reason, + }, + ); + this.readableController.error(reason); + this.rejectReadableP?.(reason); + this.dispatchEvent( + new events.EventWebSocketStreamError({ + detail: e, + }), + ); + this.streamSend({ + type: StreamMessageType.Error, + payload: { + shutdown: StreamShutdown.Write, + code, + }, + }); + } else if (shutdown === StreamShutdown.Write) { + if (this._writeClosed) return; + const code = this.reasonToCode('write', reason) as VarInt; + const e = new errors.ErrorWebSocketStreamLocalWrite( + 'Closing writable stream due to Error message from peer', + { + data: { code }, + cause: reason, + }, + ); + this.writableController.error(reason); + this.rejectWritableP?.(reason); + this.dispatchEvent( + new events.EventWebSocketStreamError({ + detail: e, + }), + ); + // TODO: change to dispatch event + this.streamSend({ + type: StreamMessageType.Error, + payload: { + shutdown: StreamShutdown.Read, + code, + }, + }); + } + } else if (parsedMessage.type === StreamMessageType.Close) { + const shutdown = parsedMessage.payload; + if (shutdown === StreamShutdown.Read) { + if (this._readClosed) return; + this.readableController.close(); + this.resolveReadableP?.(); + this.dispatchEvent(new events.EventWebSocketStreamCloseRead()); + this.streamSend({ + type: StreamMessageType.Close, + payload: StreamShutdown.Write, + }); + } else if (shutdown === StreamShutdown.Write) { + if (this._writeClosed) return; + // Realistically this should never happen due to Readable stream not being able to be closed + const code = StreamErrorCode.Unknown; + const reason = new errors.ErrorWebSocketStreamUnknown(); + const e = new errors.ErrorWebSocketStreamLocalWrite( + 'Closing writable stream due to Close message from peer', + { + data: { code }, + cause: reason, + }, + ); + this.writableController.error(reason); + this.rejectWritableP?.(reason); + this.dispatchEvent( + new events.EventWebSocketStreamError({ + detail: e, + }), + ); + this.streamSend({ + type: StreamMessageType.Error, + payload: { + shutdown: StreamShutdown.Read, + code, + }, + }); + } + } + } } export default WebSocketStream; diff --git a/src/WebSocketStreamQueue.ts b/src/WebSocketStreamQueue.ts new file mode 100644 index 00000000..deafb248 --- /dev/null +++ b/src/WebSocketStreamQueue.ts @@ -0,0 +1,91 @@ +/** + * WebSocketStreamQueue can have 3 states regarding the head and the tail: + * - if (head == null && head === tail) then the queue is empty + * - if (head != null && head === tail) then the queue has 1 item + * - if (head != null && head !== tail) then the queue has 2 or more items + */ +class WebSocketStreamQueue { + protected head?: WebSocketStreamQueueItem; + protected tail?: WebSocketStreamQueueItem; + protected _byteLength: number; + protected _length: number; + protected _count: number; + + /** + * The combined byteLength of all queued `Uint8Array`. + */ + public get byteLength(): Readonly { + return this._byteLength; + } + /** + * The combined length of the queued `Uint8Array`s. + */ + public get length(): Readonly { + return this._length; + } + /** + * The number of queued `Uint8Array`. + */ + public get count(): Readonly { + return this._count; + } + + constructor() { + this._byteLength = 0; + this._length = 0; + this._count = 0; + } + public queue(data: Uint8Array): void { + const item = { + data, + }; + // If there is no head, then this is the first item in the queue + if (this.head == null) { + this.head = item; + } + // If the tail exists, then set the next item on the tail to the new item + if (this.tail != null) { + this.tail.next = item; + } + // Set the tail to the new item + this.tail = item; + // Update the byteLength, length, and count + this._byteLength += data.byteLength; + this._length += data.length; + this._count++; + } + /** + * Returns the data of the head and removes the head from the queue. + * If the queue is empty, then undefined is returned. + */ + public dequeue(): Uint8Array | undefined { + // Get the data of the head + const oldData = this.head?.data; + const newHead = this.head?.next; + // If the head and the tail are the same, then the queue is either empty or only have one item + if (this.head === this.tail) { + this.tail = undefined; + } + this.head = newHead; + // Decrement the count, but don't let it go below 0 in case the queue is empty + this._count = this._count === 0 ? 0 : this._count - 1; + this._byteLength -= oldData?.byteLength ?? 0; + this._length -= oldData?.length ?? 0; + return oldData; + } + public clear(): void { + this._byteLength = 0; + this._length = 0; + this._count = 0; + // Clearing head and tail should cause the garbage collector to clean up all the items in the queue + this.head = undefined; + this.tail = undefined; + } +} + +type WebSocketStreamQueueItem = { + data: Uint8Array; + next?: WebSocketStreamQueueItem; +}; + +export default WebSocketStreamQueue; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 00000000..488fe80b --- /dev/null +++ b/src/config.ts @@ -0,0 +1,19 @@ +import type { WebSocketConfig } from './types'; + +const connectTimeoutTime = Infinity; + +const serverDefault: WebSocketConfig = { + keepAliveIntervalTime: Infinity, + keepAliveTimeoutTime: Infinity, + streamBufferSize: 1 * 1024 * 1024, // 1MB + verifyPeer: false, +}; + +const clientDefault: WebSocketConfig = { + keepAliveIntervalTime: Infinity, + keepAliveTimeoutTime: Infinity, + streamBufferSize: 1 * 1024 * 1024, // 1MB + verifyPeer: true, +}; + +export { connectTimeoutTime, serverDefault, clientDefault }; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 00000000..eee286b4 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,200 @@ +import type { POJO } from '@matrixai/errors'; +import type { ConnectionError } from './types'; +import { AbstractError } from '@matrixai/errors'; + +class ErrorWebSocket extends AbstractError { + static description = 'WebSocket error'; +} + +class ErrorWebSocketHostInvalid extends AbstractError { + static description = 'Host provided was not valid'; +} + +class ErrorWebSocketPortInvalid extends AbstractError { + static description = 'Port provided was not valid'; +} + +// Server + +class ErrorWebSocketServer extends ErrorWebSocket { + static description = 'WebSocket Server error'; +} + +class ErrorWebSocketServerNotRunning extends ErrorWebSocketServer { + static description = 'WebSocket Server is not running'; +} + +class ErrorWebSocketServerInternal extends ErrorWebSocketServer { + static description = 'WebSocket Server internal error'; +} + +// Client + +class ErrorWebSocketClient extends ErrorWebSocket { + static description = 'WebSocket Client error'; +} + +class ErrorWebSocketClientCreateTimeOut extends ErrorWebSocketClient { + static description = 'WebSocketC Client create timeout'; +} + +class ErrorWebSocketClientDestroyed extends ErrorWebSocketClient { + static description = 'WebSocket Client is destroyed'; +} + +// Connection + +class ErrorWebSocketConnection extends ErrorWebSocket { + static description = 'WebSocket Connection error'; +} + +class ErrorWebSocketConnectionNotRunning< + T, +> extends ErrorWebSocketConnection { + static description = 'WebSocket Connection is not running'; +} + +class ErrorWebSocketConnectionClosed extends ErrorWebSocketConnection { + static description = + 'WebSocket Connection cannot be restarted because it has already been closed'; +} + +class ErrorWebSocketConnectionStartTimeOut< + T, +> extends ErrorWebSocketConnection { + static description = 'WebSocket Connection start timeout'; +} + +class ErrorWebSocketConnectionKeepAliveTimeOut< + T, +> extends ErrorWebSocketConnection { + static description = 'WebSocket Connection reached keep-alive timeout'; +} + +class ErrorWebSocketConnectionInternal extends ErrorWebSocketConnection { + static description = 'WebSocket Connection internal error'; +} + +/** + * Note that TlsFail error codes are documented here: + * https://github.com/google/boringssl/blob/master/include/openssl/ssl.h + */ +class ErrorWebSocketConnectionLocal extends ErrorWebSocketConnection { + static description = 'WebSocket Connection local error'; + declare data: POJO & ConnectionError; + constructor( + message: string = '', + options: { + timestamp?: Date; + data: POJO & ConnectionError; + cause?: T; + }, + ) { + super(message, options); + } +} + +class ErrorWebSocketConnectionLocalTLS< + T, +> extends ErrorWebSocketConnectionLocal { + static description = 'WebSocket Connection local TLS error'; +} + +class ErrorWebSocketConnectionPeer extends ErrorWebSocketConnection { + static description = 'WebSocket Connection peer error'; + declare data: POJO & ConnectionError; + constructor( + message: string = '', + options: { + timestamp?: Date; + data: POJO & ConnectionError; + cause?: T; + }, + ) { + super(message, options); + } +} + +// Stream + +class ErrorWebSocketStream extends ErrorWebSocket { + static description = 'WebSocket Stream error'; +} + +class ErrorWebSocketStreamDestroyed extends ErrorWebSocketStream { + static description = 'WebSocket Stream is destroyed'; +} + +class ErrorWebSocketStreamLocalRead extends ErrorWebSocketStream { + static description = 'WebSocket Stream locally closed readable side'; +} + +class ErrorWebSocketStreamLocalWrite extends ErrorWebSocketStream { + static description = 'WebSocket Stream locally closed writable side'; +} + +class ErrorWebSocketStreamPeerRead extends ErrorWebSocketStream { + static description = 'WebSocket Stream peer closed readable side'; +} + +class ErrorWebSocketStreamPeerWrite extends ErrorWebSocketStream { + static description = 'WebSocket Stream peer closed writable side'; +} + +class ErrorWebSocketStreamInternal extends ErrorWebSocketStream { + static description = 'WebSocket Stream internal error'; +} + +// Stream Protocol Errors + +class ErrorWebSocketStreamUnknown extends ErrorWebSocketStream { + static description = 'WebSocket Stream readable buffer has overloaded'; +} + +class ErrorWebSocketStreamReadableParse extends ErrorWebSocketStream { + static description = 'WebSocket Stream readable buffer has overloaded'; +} + +class ErrorWebSocketStreamReadableBufferOverload< + T, +> extends ErrorWebSocketStream { + static description = 'WebSocket Stream readable buffer has overloaded'; +} + +// Misc + +class ErrorWebSocketUndefinedBehaviour extends ErrorWebSocket { + static description = 'This should never happen'; +} + +export { + ErrorWebSocket, + ErrorWebSocketHostInvalid, + ErrorWebSocketPortInvalid, + ErrorWebSocketServer, + ErrorWebSocketServerNotRunning, + ErrorWebSocketServerInternal, + ErrorWebSocketClient, + ErrorWebSocketClientCreateTimeOut, + ErrorWebSocketClientDestroyed, + ErrorWebSocketConnection, + ErrorWebSocketConnectionNotRunning, + ErrorWebSocketConnectionClosed, + ErrorWebSocketConnectionStartTimeOut, + ErrorWebSocketConnectionKeepAliveTimeOut, + ErrorWebSocketConnectionLocal, + ErrorWebSocketConnectionLocalTLS, + ErrorWebSocketConnectionPeer, + ErrorWebSocketConnectionInternal, + ErrorWebSocketStream, + ErrorWebSocketStreamDestroyed, + ErrorWebSocketStreamLocalRead, + ErrorWebSocketStreamLocalWrite, + ErrorWebSocketStreamPeerRead, + ErrorWebSocketStreamPeerWrite, + ErrorWebSocketStreamInternal, + ErrorWebSocketStreamUnknown, + ErrorWebSocketStreamReadableParse, + ErrorWebSocketStreamReadableBufferOverload, + ErrorWebSocketUndefinedBehaviour, +}; diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 00000000..c8f84006 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,166 @@ +import type WebSocketStream from './WebSocketStream'; +import type WebSocketConnection from './WebSocketConnection'; +import type { + ErrorWebSocketConnectionInternal, + ErrorWebSocketConnectionKeepAliveTimeOut, + ErrorWebSocketConnectionLocal, + ErrorWebSocketConnectionPeer, + ErrorWebSocketStreamInternal, + ErrorWebSocketStreamLocalRead, + ErrorWebSocketStreamLocalWrite, + ErrorWebSocketStreamPeerRead, + ErrorWebSocketStreamPeerWrite, +} from './errors'; +import { AbstractEvent } from '@matrixai/events'; + +abstract class EventWebSocket extends AbstractEvent {} + +// Client Events + +abstract class EventWebSocketClient extends EventWebSocket {} + +class EventWebSocketClientDestroy extends EventWebSocketClient {} + +class EventWebSocketClientDestroyed extends EventWebSocketClient {} + +class EventWebSocketClientError extends EventWebSocketClient {} + +class EventWebSocketClientClose extends EventWebSocketClient {} + +// Server events + +abstract class EventWebSocketServer extends EventWebSocket {} + +class EventWebSocketServerConnection extends EventWebSocketServer {} + +class EventWebSocketServerStart extends EventWebSocketServer {} + +class EventWebSocketServerStarted extends EventWebSocketServer {} + +class EventWebSocketServerStop extends EventWebSocketServer {} + +class EventWebSocketServerStopped extends EventWebSocketServer {} + +class EventWebSocketServerError extends EventWebSocketServer {} + +class EventWebSocketServerClose extends EventWebSocketServer {} + +// Connection events + +abstract class EventWebSocketConnection extends EventWebSocket {} + +class EventWebSocketConnectionStream extends EventWebSocketConnection {} + +class EventWebSocketConnectionStart extends EventWebSocketConnection {} + +class EventWebSocketConnectionStarted extends EventWebSocketConnection {} + +class EventWebSocketConnectionStop extends EventWebSocketConnection {} + +class EventWebSocketConnectionStopped extends EventWebSocketConnection {} + +class EventWebSocketConnectionError extends EventWebSocketConnection< + | ErrorWebSocketConnectionLocal + | ErrorWebSocketConnectionPeer + | ErrorWebSocketConnectionKeepAliveTimeOut + | ErrorWebSocketConnectionInternal +> {} + +class EventWebSocketConnectionClose extends EventWebSocketConnection< + | ErrorWebSocketConnectionLocal + | ErrorWebSocketConnectionPeer + | ErrorWebSocketConnectionKeepAliveTimeOut +> {} + +// Stream events + +abstract class EventWebSocketStream extends EventWebSocket {} + +class EventWebSocketStreamStart extends EventWebSocketStream {} + +class EventWebSocketStreamStarted extends EventWebSocketStream {} + +class EventWebSocketStreamStop extends EventWebSocketStream {} + +class EventWebSocketStreamStopped extends EventWebSocketStream {} + +// Note that you can close the readable side, and give a code +// You can close the writable side, and give a code +// Neither of which represents an "error" for the WebSocket stream error? +// Or does it? +// Because one could argue either way, and then have a way to separate +// Intenral errors from non-internal errors + +/** + * WebSocket stream encountered an error. + * Unlike WebSocketConnection, you can just have graceful close without any error event at all. + * This is because streams can just be finished with no code. + * But WebSocketConnection closure always comes with some error code and reason, even if the code is 0. + */ +class EventWebSocketStreamError extends EventWebSocketStream< + | ErrorWebSocketStreamLocalRead // I may send out errors on both stop sending (shutdown read) and reset stream (shutdown write) + | ErrorWebSocketStreamLocalWrite // I may send out errors on both stop sending (shutdown read) and reset stream (shutdown write) + | ErrorWebSocketStreamPeerRead // I may receive errors on both stop sending (shutdown read) and reset stream (shutdown write) + | ErrorWebSocketStreamPeerWrite // I may receive errors on both stop sending (shutdown read) and reset stream (shutdown write) + | ErrorWebSocketStreamInternal +> {} + +/** + * WebSocket stream readable side was closed + * Local means I closed my readable side - there must be an error code. + * Peer means the peer closed my readable side by closing their writable side - there may not be an error code. + */ +class EventWebSocketStreamCloseRead extends EventWebSocketStream< + | ErrorWebSocketStreamLocalRead + | ErrorWebSocketStreamPeerRead + | undefined +> {} + +/** + * WebSocket stream writable side was closed + * Local means I closed my writable side - there may not be an error code. + * Peer means the peer closed my writable side by closing their readable side - there must be an error code. + */ +class EventWebSocketStreamCloseWrite extends EventWebSocketStream< + | ErrorWebSocketStreamLocalWrite + | ErrorWebSocketStreamPeerWrite + | undefined +> {} + +class EventWebSocketStreamSend extends EventWebSocketStream { + msg: Uint8Array | Array; +} + +export { + EventWebSocket, + EventWebSocketClient, + EventWebSocketClientError, + EventWebSocketClientDestroy, + EventWebSocketClientDestroyed, + EventWebSocketClientClose, + EventWebSocketServer, + EventWebSocketServerConnection, + EventWebSocketServerStart, + EventWebSocketServerStarted, + EventWebSocketServerStop, + EventWebSocketServerStopped, + EventWebSocketServerError, + EventWebSocketServerClose, + EventWebSocketConnection, + EventWebSocketConnectionStream, + EventWebSocketConnectionStart, + EventWebSocketConnectionStarted, + EventWebSocketConnectionStop, + EventWebSocketConnectionStopped, + EventWebSocketConnectionError, + EventWebSocketConnectionClose, + EventWebSocketStream, + EventWebSocketStreamStart, + EventWebSocketStreamStarted, + EventWebSocketStreamStop, + EventWebSocketStreamStopped, + EventWebSocketStreamError, + EventWebSocketStreamCloseRead, + EventWebSocketStreamCloseWrite, + EventWebSocketStreamSend, +}; diff --git a/src/index.ts b/src/index.ts index eea524d6..aeb3cb13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,12 @@ -export * from "./types"; +export { default as WebSocketServer } from './WebSocketServer'; +export { default as WebSocketClient } from './WebSocketClient'; +export { default as WebSocketConnection } from './WebSocketConnection'; +export { default as WebSocketStream } from './WebSocketStream'; +export { default as WebSocketStreamQueue } from './WebSocketStreamQueue'; + +export * as types from './types'; +export * as utils from './utils'; +export * as events from './events'; +export * as errors from './errors'; +export * as config from './config'; +export * as message from './message'; diff --git a/src/message/errors.ts b/src/message/errors.ts new file mode 100644 index 00000000..b179a16c --- /dev/null +++ b/src/message/errors.ts @@ -0,0 +1,15 @@ +import { AbstractError } from '@matrixai/errors'; + +class ErrorStreamMessage extends AbstractError { + static description = 'Stream Message error'; +} + +class ErrorStreamParse extends ErrorStreamMessage { + static description = 'Stream Message parse error'; +} + +class ErrorStreamGenerate extends ErrorStreamMessage { + static description = 'Stream Message generation error'; +} + +export { ErrorStreamMessage, ErrorStreamParse, ErrorStreamGenerate }; diff --git a/src/message/index.ts b/src/message/index.ts new file mode 100644 index 00000000..1694af65 --- /dev/null +++ b/src/message/index.ts @@ -0,0 +1,3 @@ +export * from './utils'; +export * from './types'; +export * from './errors'; diff --git a/src/message/types.ts b/src/message/types.ts new file mode 100644 index 00000000..f680fffe --- /dev/null +++ b/src/message/types.ts @@ -0,0 +1,62 @@ +import type { Opaque } from '@/types'; +import type { StreamMessageType, StreamShutdown } from './utils'; + +interface Parsed { + data: T; + remainder: Uint8Array; +} + +/** + * VarInt is a 62 bit unsigned integer + */ +type VarInt = Opaque<'VarInt', bigint>; + +/** + * StreamId is a VarInt + */ +type StreamId = VarInt; + +type ConnectionMessage = { + streamId: StreamId; +} & StreamMessage; + +type StreamMessage = + | StreamMessageAck + | StreamMessageData + | StreamMessageClose + | StreamMessageError; + +type StreamMessageBase = { + type: StreamMessageType; + payload: PayloadType; +}; + +type StreamMessageAck = StreamMessageBase; + +type StreamMessageData = StreamMessageBase; + +type StreamMessageClose = StreamMessageBase< + StreamMessageType.Close, + StreamShutdown +>; + +type StreamMessageError = StreamMessageBase< + StreamMessageType.Error, + { + shutdown: StreamShutdown; + code: VarInt; + } +>; + +export type { + Parsed, + VarInt, + StreamId, + ConnectionMessage, + StreamMessage, + StreamMessageBase, + StreamMessageAck, + StreamMessageData, + StreamMessageClose, + StreamMessageError, +}; diff --git a/src/message/utils.ts b/src/message/utils.ts new file mode 100644 index 00000000..9c59da2f --- /dev/null +++ b/src/message/utils.ts @@ -0,0 +1,369 @@ +import type { + ConnectionMessage, + Parsed, + StreamId, + StreamMessage, + VarInt, +} from './types'; +import { never } from '@/utils'; +import * as errors from './errors'; + +// Enums + +const enum StreamMessageType { + Data = 0, + Ack = 1, + Error = 2, + Close = 3, +} + +const enum StreamShutdown { + Read = 0, + Write = 1, +} + +const StreamErrorCode = { + Unknown: 0n as VarInt, + ErrorReadableStreamParse: 1n as VarInt, + ErrorReadableStreamBufferOverflow: 2n as VarInt, +} as const; + +// Misc + +function bufferAllocUnsafe(size: number): Uint8Array { + return globalThis.Buffer == null + ? new Uint8Array(size) + : Buffer.allocUnsafe(size); +} + +function concatUInt8Array(...arrays: Array) { + const totalLength = arrays.reduce((acc, val) => acc + val.length, 0); + const result = bufferAllocUnsafe(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +// VarInt + +function parseVarInt(array: Uint8Array): Parsed { + let streamId: bigint; + + // Get header and prefix + const header = array.at(0); + if (header == null) { + throw new errors.ErrorStreamParse('VarInt header is too short'); + } + const prefix = header >> 6; + + // Copy bytearray and remove prefix + const arrayCopy = bufferAllocUnsafe(array.length); + arrayCopy.set(array); + arrayCopy[0] &= 0b00111111; + + const dv = new DataView( + arrayCopy.buffer, + arrayCopy.byteOffset, + array.byteLength, + ); + + let readBytes = 0; + + try { + switch (prefix) { + case 0b00: + readBytes = 1; + streamId = BigInt(dv.getUint8(0)); + break; + case 0b01: + readBytes = 2; + streamId = BigInt(dv.getUint16(0, false)); + break; + case 0b10: + readBytes = 4; + streamId = BigInt(dv.getUint32(0, false)); + break; + case 0b11: + readBytes = 8; + streamId = dv.getBigUint64(0, false); + break; + } + } catch (e) { + throw new errors.ErrorStreamParse('VarInt is too short'); + } + return { + data: streamId! as VarInt, + remainder: array.subarray(readBytes), + }; +} + +function generateVarInt(varInt: VarInt): Uint8Array { + let array: Uint8Array; + let dv: DataView; + let prefixMask = 0; + + if (varInt < 0x40) { + array = bufferAllocUnsafe(1); + dv = new DataView(array.buffer, array.byteOffset, array.byteLength); + dv.setUint8(0, Number(varInt)); + } else if (varInt < 0x4000) { + array = bufferAllocUnsafe(2); + dv = new DataView(array.buffer, array.byteOffset, array.byteLength); + dv.setUint16(0, Number(varInt)); + prefixMask = 0b01_000000; + } else if (varInt < 0x40000000) { + array = bufferAllocUnsafe(4); + dv = new DataView(array.buffer, array.byteOffset, array.byteLength); + dv.setUint32(0, Number(varInt)); + prefixMask = 0b10_000000; + } else if (varInt < 0x4000000000000000n) { + array = bufferAllocUnsafe(8); + dv = new DataView(array.buffer, array.byteOffset, array.byteLength); + dv.setBigUint64(0, varInt); + prefixMask = 0b11_000000; + } else { + throw new errors.ErrorStreamGenerate('VarInt too large'); + } + + let header = dv.getUint8(0); + header |= prefixMask; + dv.setUint8(0, header); + + return array; +} + +const generateStreamId = generateVarInt as (streamId: StreamId) => Uint8Array; +const parseStreamId = parseVarInt as (array: Uint8Array) => Parsed; + +// StreamMessage + +function parseStreamMessageType(input: Uint8Array): Parsed { + const dv = new DataView(input.buffer, input.byteOffset, input.byteLength); + let type: number | undefined; + try { + type = dv.getUint16(0, false); + } catch (e) { + throw new errors.ErrorStreamParse( + 'StreamMessage does not contain a StreamMessageType', + { + cause: e, + }, + ); + } + switch (type) { + case StreamMessageType.Ack: + case StreamMessageType.Data: + case StreamMessageType.Close: + case StreamMessageType.Error: + return { + data: type, + remainder: input.subarray(2), + }; + default: + throw new errors.ErrorStreamParse( + `StreamMessage contains an invalid StreamMessageType: ${type}`, + ); + } +} + +function parseStreamMessageAckPayload(input: Uint8Array): Parsed { + const dv = new DataView(input.buffer, input.byteOffset, input.byteLength); + if (input.byteLength < 4) { + throw new errors.ErrorStreamParse('StreamMessageAckPayload is too short'); + } + const payload = dv.getUint32(0, false); + return { + data: payload, + remainder: input.subarray(4), + }; +} + +function parseStreamMessageClosePayload( + input: Uint8Array, +): Parsed { + const shutdown = input.at(0); + if (shutdown == null) { + throw new errors.ErrorStreamParse( + 'StreamMessageClosePayload does not contain a StreamShutdown', + ); + } + if (shutdown !== StreamShutdown.Read && shutdown !== StreamShutdown.Write) { + throw new errors.ErrorStreamParse( + `StreamMessageClosePayload contains an invalid StreamShutdown: ${shutdown}`, + ); + } + return { + data: shutdown, + remainder: input.subarray(1), + }; +} + +function parseStreamMessageErrorPayload( + input: Uint8Array, +): Parsed<{ shutdown: StreamShutdown; code: VarInt }> { + let remainder = input; + + const shutdown = input.at(0); + if (shutdown == null) { + throw new errors.ErrorStreamParse( + 'StreamMessageErrorPayload does not contain a StreamShutdown', + ); + } + if (shutdown !== StreamShutdown.Read && shutdown !== StreamShutdown.Write) { + throw new errors.ErrorStreamParse( + `StreamMessageErrorPayload contains an invalid StreamShutdown: ${shutdown}`, + ); + } + remainder = remainder.subarray(1); + + const { data: code, remainder: postCodeRemainder } = parseVarInt(remainder); + remainder = postCodeRemainder; + + return { + data: { + shutdown, + code, + }, + remainder, + }; +} + +function parseStreamMessage(input: Uint8Array): StreamMessage { + let remainder = input; + + const { data: type, remainder: postTypeRemainder } = + parseStreamMessageType(remainder); + remainder = postTypeRemainder; + + let payload: any; + if (type === StreamMessageType.Ack) { + const { data: ackPayload, remainder: postAckPayloadRemainder } = + parseStreamMessageAckPayload(remainder); + remainder = postAckPayloadRemainder; + payload = ackPayload; + } else if (type === StreamMessageType.Data) { + payload = remainder; + } else if (type === StreamMessageType.Close) { + const { data: closePayload, remainder: postClosePayloadRemainder } = + parseStreamMessageClosePayload(remainder); + remainder = postClosePayloadRemainder; + payload = closePayload; + } else if (type === StreamMessageType.Error) { + const { data: errorPayload, remainder: postErrorPayloadRemainder } = + parseStreamMessageErrorPayload(remainder); + remainder = postErrorPayloadRemainder; + payload = errorPayload; + } else { + never(); + } + + return { + type, + payload, + }; +} + +function generateStreamMessageType(type: StreamMessageType): Uint8Array { + const array = bufferAllocUnsafe(2); + const dv = new DataView(array.buffer, array.byteOffset, array.byteLength); + dv.setUint16(0, type, false); + return array; +} + +function generateStreamMessageAckPayload(ackPayload: number): Uint8Array { + if (ackPayload > 0xffffffff) { + throw new errors.ErrorStreamGenerate( + 'StreamMessageAckPayload is too large', + ); + } + const array = bufferAllocUnsafe(4); + const dv = new DataView(array.buffer, array.byteOffset, array.byteLength); + dv.setUint32(0, ackPayload, false); + return array; +} + +function generateStreamMessageClosePayload( + closePayload: StreamShutdown, +): Uint8Array { + return new Uint8Array([closePayload]); +} + +function generateStreamMessageErrorPayload(errorPayload: { + shutdown: StreamShutdown; + code: VarInt; +}): Uint8Array { + const generatedCode = generateVarInt(errorPayload.code); + const array = bufferAllocUnsafe(1 + generatedCode.length); + array[0] = errorPayload.shutdown; + array.set(generatedCode, 1); + return array; +} + +function generateStreamMessage(input: StreamMessage, concat?: true): Uint8Array; +function generateStreamMessage( + input: StreamMessage, + concat: false, +): Array; +function generateStreamMessage(input: StreamMessage, concat = true) { + const generatedType = generateStreamMessageType(input.type); + let generatedPayload: Uint8Array; + if (input.type === StreamMessageType.Ack) { + generatedPayload = generateStreamMessageAckPayload(input.payload); + } else if (input.type === StreamMessageType.Data) { + generatedPayload = input.payload; + } else if (input.type === StreamMessageType.Close) { + generatedPayload = generateStreamMessageClosePayload(input.payload); + } else if (input.type === StreamMessageType.Error) { + generatedPayload = generateStreamMessageErrorPayload(input.payload); + } else { + never(); + } + if (!concat) { + return [generatedType, generatedPayload]; + } + return concatUInt8Array(generatedType, generatedPayload); +} + +// Connection Message + +function parseConnectionMessage(input: Uint8Array): ConnectionMessage { + const { data: streamId, remainder } = parseStreamId(input); + const streamMessage = parseStreamMessage(remainder); + return { + streamId, + ...streamMessage, + }; +} + +function generateConnectionMessage(input: ConnectionMessage): Uint8Array { + const generatedStreamId = generateStreamId(input.streamId); + const generatedStreamMessage = generateStreamMessage(input); + return concatUInt8Array(generatedStreamId, generatedStreamMessage); +} + +export { + StreamMessageType, + StreamShutdown, + StreamErrorCode, + bufferAllocUnsafe, + concatUInt8Array, + parseVarInt, + generateVarInt, + parseStreamId, + generateStreamId, + parseStreamMessageType, + parseStreamMessageAckPayload, + parseStreamMessageClosePayload, + parseStreamMessageErrorPayload, + parseStreamMessage, + generateStreamMessageType, + generateStreamMessageAckPayload, + generateStreamMessageClosePayload, + generateStreamMessageErrorPayload, + generateStreamMessage, + parseConnectionMessage, + generateConnectionMessage, +}; diff --git a/src/types.ts b/src/types.ts index f668d518..53b5f4ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1 +1,192 @@ -export type {} +// Async + +/** + * Generic callback + */ +type Callback

= [], R = any, E extends Error = Error> = { + (e: E, ...params: Partial

): R; + (e?: null | undefined, ...params: P): R; +}; + +/** + * Deconstructed promise + */ +type PromiseDeconstructed = { + p: Promise; + resolveP: (value: T | PromiseLike) => void; + rejectP: (reason?: any) => void; +}; + +// Opaque + +/** + * Opaque types are wrappers of existing types + * that require smart constructors + */ +type Opaque = T & { readonly [brand]: K }; +declare const brand: unique symbol; + +type ConnectionId = Opaque<'ConnectionId', number>; + +/** + * Host is always an IP address + */ +type Host = Opaque<'Host', string>; + +/** + * Hostnames are resolved to IP addresses + */ +type Hostname = Opaque<'Hostname', string>; + +/** + * Ports are numbers from 0 to 65535 + */ +type Port = Opaque<'Port', number>; + +/** + * Combination of `:` + */ +type Address = Opaque<'Address', string>; + +// Misc + +/** + * Custom hostname resolution. It is expected this returns an IP address. + */ +type ResolveHostname = (hostname: string) => string | PromiseLike; + +/** + * Maps reason (most likely an exception) to a stream code. + * Use `0` to indicate unknown/default reason. + */ +type StreamReasonToCode = (type: 'read' | 'write', reason?: any) => bigint; + +/** + * Maps code to a reason. 0 usually indicates unknown/default reason. + */ +type StreamCodeToReason = (type: 'read' | 'write', code: bigint) => any; + +type ConnectionMetadata = { + localHost?: string; + localPort?: number; + remoteHost: string; + remotePort: number; + localCertsChain: Array; + localCACertsChain: Array; + remoteCertsChain: Array; +}; + +type TLSVerifyCallback = ( + certs: Array, + ca: Array, +) => PromiseLike; + +type WebSocketConfig = { + /** + * Certificate authority certificate in PEM format or Uint8Array buffer + * containing PEM formatted certificate. Each string or Uint8Array can be + * one certificate or multiple certificates concatenated together. The order + * does not matter, each is an independent certificate authority. Multiple + * concatenated certificate authorities can be passed. They are all + * concatenated together. + * + * When this is not set, this defaults to the operating system's CA + * certificates. OpenSSL (and forks of OpenSSL) all support the + * environment variables `SSL_CERT_DIR` and `SSL_CERT_FILE`. + */ + ca?: string | Array | Uint8Array | Array; + + /** + * Private key as a PEM string or Uint8Array buffer containing PEM formatted + * key. You can pass multiple keys. The number of keys must match the number + * of certs. Each key must be associated to the the corresponding cert chain. + * + * Currently multiple key and certificate chains is not supported. + */ + key?: string | Array | Uint8Array | Array; + + /** + * X.509 certificate chain in PEM format or Uint8Array buffer containing + * PEM formatted certificate chain. Each string or Uint8Array is a + * certificate chain in subject to issuer order. Multiple certificate chains + * can be passed. The number of certificate chains must match the number of + * keys. Each certificate chain must be associated to the corresponding key. + * + * Currently multiple key and certificate chains is not supported. + */ + cert?: string | Array | Uint8Array | Array; + + /** + * Verify the other peer. + * Clients by default set this to true. + * Servers by default set this to false. + * Servers will not request peer certs unless this is true. + * Server certs are always sent + */ + verifyPeer: boolean; + + /** + * Custom TLS verification callback. + * It is expected that the callback will throw an error if the verification + * fails. + * Will be ignored if `verifyPeer` is false. + */ + verifyCallback?: TLSVerifyCallback; + + keepAliveTimeoutTime: number; + /** + * This controls the interval for keeping alive an idle connection. + * This time will be used to send a ping frame to keep the connection alive. + * This is only useful if the `maxIdleTimeout` is set to greater than 0. + * This is defaulted to `undefined`. + * This is not a quiche option. + */ + keepAliveIntervalTime: number; + /** + * Maximum number of bytes for the readable stream + */ + streamBufferSize: number; +}; + +type WebSocketClientConfigInput = Partial; + +type WebSocketServerConfigInput = Partial & { + key: string | Array | Uint8Array | Array; + cert: string | Array | Uint8Array | Array; +}; + +type WebSocketServerConfigInputWithInjectedServer = Partial< + WebSocketConfig & { + key: undefined; + cert: undefined; + ca: undefined; + verifyCallback: undefined; + verifyPeer: undefined; + } +>; + +type ConnectionError = { + errorCode: number; + reason: string; +}; + +export type { + Opaque, + Callback, + PromiseDeconstructed, + ConnectionId, + Host, + Hostname, + Port, + Address, + ResolveHostname, + StreamReasonToCode, + StreamCodeToReason, + ConnectionMetadata, + TLSVerifyCallback, + WebSocketConfig, + WebSocketClientConfigInput, + WebSocketServerConfigInput, + WebSocketServerConfigInputWithInjectedServer, + ConnectionError, +}; diff --git a/src/utils.ts b/src/utils.ts index e69de29b..f62f6f78 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -0,0 +1,496 @@ +import type { Callback, Host, Port, PromiseDeconstructed } from './types'; +import type { DetailedPeerCertificate } from 'tls'; +import * as dns from 'dns'; +import { IPv4, IPv6, Validator } from 'ip-num'; +import * as errors from './errors'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder('utf-8'); + +function never(message?: string): never { + throw new errors.ErrorWebSocketUndefinedBehaviour(message); +} + +/** + * Is it an IPv4 address? + */ +function isIPv4(host: string): host is Host { + const [isIPv4] = Validator.isValidIPv4String(host); + return isIPv4; +} + +/** + * Is it an IPv6 address? + * This considers IPv4 mapped IPv6 addresses to also be IPv6 addresses. + */ +function isIPv6(host: string): host is Host { + const [isIPv6] = Validator.isValidIPv6String(host); + if (isIPv6) return true; + // Test if the host is an IPv4 mapped IPv6 address. + // In the future, `isValidIPv6String` should be able to handle this + // and this code can be removed. + return isIPv4MappedIPv6(host); +} + +/** + * There are 2 kinds of IPv4 mapped IPv6 addresses. + * 1. ::ffff:127.0.0.1 - dotted decimal version + * 2. ::ffff:7f00:1 - hex version + * Both are accepted by Node's dgram module. + */ +function isIPv4MappedIPv6(host: string): host is Host { + if (host.startsWith('::ffff:')) { + try { + // The `ip-num` package understands `::ffff:7f00:1` + IPv6.fromString(host); + return true; + } catch { + // But it does not understand `::ffff:127.0.0.1` + const ipv4 = host.slice('::ffff:'.length); + if (isIPv4(ipv4)) { + return true; + } + } + } + return false; +} + +function isIPv4MappedIPv6Hex(host: string): host is Host { + if (host.startsWith('::ffff:')) { + try { + // The `ip-num` package understands `::ffff:7f00:1` + IPv6.fromString(host); + return true; + } catch { + return false; + } + } + return false; +} + +function isIPv4MappedIPv6Dec(host: string): host is Host { + if (host.startsWith('::ffff:')) { + // But it does not understand `::ffff:127.0.0.1` + const ipv4 = host.slice('::ffff:'.length); + if (isIPv4(ipv4)) { + return true; + } + } + return false; +} + +/** + * Takes an IPv4 address and returns the IPv4 mapped IPv6 address. + * This produces the dotted decimal variant. + */ +function toIPv4MappedIPv6Dec(host: string): Host { + if (!isIPv4(host)) { + throw new TypeError('Invalid IPv4 address'); + } + return ('::ffff:' + host) as Host; +} + +/** + * Takes an IPv4 address and returns the IPv4 mapped IPv6 address. + * This produces the dotted Hexidecimal variant. + */ +function toIPv4MappedIPv6Hex(host: string): Host { + if (!isIPv4(host)) { + throw new TypeError('Invalid IPv4 address'); + } + return IPv4.fromString(host).toIPv4MappedIPv6().toString() as Host; +} + +/** + * Extracts the IPv4 portion out of the IPv4 mapped IPv6 address. + * Can handle both the dotted decimal and hex variants. + * 1. ::ffff:7f00:1 + * 2. ::ffff:127.0.0.1 + * Always returns the dotted decimal variant. + */ +function fromIPv4MappedIPv6(host: string): Host { + const ipv4 = host.slice('::ffff:'.length); + if (isIPv4(ipv4)) { + return ipv4 as Host; + } + const matches = ipv4.match(/^([0-9a-fA-F]{1,4}):([0-9a-fA-F]{1,4})$/); + if (matches == null) { + throw new TypeError('Invalid IPv4 mapped IPv6 address'); + } + const ipv4Hex = matches[1].padStart(4, '0') + matches[2].padStart(4, '0'); + const ipv4Hexes = ipv4Hex.match(/.{1,2}/g)!; + const ipv4Decs = ipv4Hexes.map((h) => parseInt(h, 16)); + return ipv4Decs.join('.') as Host; +} + +/** + * This converts all `IPv4` formats to the `IPv4` decimal format. + * `IPv4` decimal and `IPv6` hex formatted IPs are left unchanged. + */ +function toCanonicalIp(host: string) { + if (isIPv4MappedIPv6(host)) { + return fromIPv4MappedIPv6(host); + } + if (isIPv4(host) || isIPv6(host)) { + return host; + } + throw new TypeError('Invalid IP address'); +} + +/** + * This will resolve a hostname to the first host. + * It could be an IPv6 address or IPv4 address. + * This uses the OS's DNS resolution system. + */ +async function resolveHostname(hostname: string): Promise { + const result = await dns.promises.lookup(hostname, { + family: 0, + all: false, + verbatim: true, + }); + return result.address as Host; +} + +/** + * This will resolve a Host or Hostname to Host and `udp4` or `udp6`. + * The `resolveHostname` can be overridden. + */ +async function resolveHost( + host: string, + resolveHostname: (hostname: string) => string | PromiseLike, +): Promise<[Host, 'udp4' | 'udp6']> { + if (isIPv4(host)) { + return [host as Host, 'udp4']; + } else if (isIPv6(host)) { + return [host as Host, 'udp6']; + } else { + try { + host = await resolveHostname(host); + return resolveHost(host, resolveHostname); + } catch { + // Todo specific resolve host error. + throw new errors.ErrorWebSocketHostInvalid(); + } + } +} + +/** + * Is it a valid Port? + */ +function isPort(port: any): port is Port { + if (typeof port !== 'number') return false; + return port >= 0 && port <= 65535; +} + +/** + * Throws if port is invalid, otherwise returns port as Port. + */ +function toPort(port: any): Port { + // Todo specific resolve host error.` + if (!isPort(port)) throw new errors.ErrorWebSocketPortInvalid(); + return port; +} + +/** + * Convert callback-style to promise-style + * If this is applied to overloaded function + * it will only choose one of the function signatures to use + */ +function promisify< + T extends Array, + P extends Array, + R extends T extends [] ? void : T extends [unknown] ? T[0] : T, +>( + f: (...args: [...params: P, callback: Callback]) => unknown, +): (...params: P) => Promise { + // Uses a regular function so that `this` can be bound + return function (...params: P): Promise { + return new Promise((resolve, reject) => { + const callback = (error, ...values) => { + if (error != null) { + return reject(error); + } + if (values.length === 0) { + (resolve as () => void)(); + } else if (values.length === 1) { + resolve(values[0] as R); + } else { + resolve(values as R); + } + return; + }; + params.push(callback); + f.apply(this, params); + }); + }; +} + +/** + * Deconstructed promise + */ +function promise(): PromiseDeconstructed { + let resolveP, rejectP; + const p = new Promise((resolve, reject) => { + resolveP = resolve; + rejectP = reject; + }); + return { + p, + resolveP, + rejectP, + }; +} + +/** + * Zero-copy wraps ArrayBuffer-like objects into Buffer + * This supports ArrayBuffer, TypedArrays and the NodeJS Buffer + */ +function bufferWrap( + array: BufferSource, + offset?: number, + length?: number, +): Buffer { + if (Buffer.isBuffer(array)) { + return array; + } else if (ArrayBuffer.isView(array)) { + return Buffer.from( + array.buffer, + offset ?? array.byteOffset, + length ?? array.byteLength, + ); + } else { + return Buffer.from(array, offset, length); + } +} + +/** + * Given host and port, create an address string. + */ +function buildAddress(host: string, port: number = 0): string { + let address: string; + if (isIPv4(host)) { + address = `${host}:${port}`; + } else if (isIPv6(host)) { + address = `[${host}]:${port}`; + } else { + address = `${host}:${port}`; + } + return address; +} + +function isHostWildcard(host: Host): boolean { + return ( + host === '0.0.0.0' || + host === '::' || + host === '::0' || + host === '::ffff:0.0.0.0' || + host === '::ffff:0:0' + ); +} + +/** + * Zero IPs should be resolved to localhost when used as the target + */ +function resolvesZeroIP(host: Host): Host { + const zeroIPv4 = new IPv4('0.0.0.0'); + // This also covers `::0` + const zeroIPv6 = new IPv6('::'); + if (isIPv4MappedIPv6(host)) { + const ipv4 = fromIPv4MappedIPv6(host); + if (new IPv4(ipv4).isEquals(zeroIPv4)) { + return toIPv4MappedIPv6Dec('127.0.0.1'); + } else { + return host; + } + } else if (isIPv4(host) && new IPv4(host).isEquals(zeroIPv4)) { + return '127.0.0.1' as Host; + } else if (isIPv6(host) && new IPv6(host).isEquals(zeroIPv6)) { + return '::1' as Host; + } else { + return host; + } +} + +// Async function mintToken( +// dcid: QUICConnectionId, +// peerHost: Host, +// crypto: QUICServerCrypto, +// ): Promise { +// const msgData = { dcid: dcid.toString(), host: peerHost }; +// const msgJSON = JSON.stringify(msgData); +// const msgBuffer = Buffer.from(msgJSON); +// const msgSig = Buffer.from(await crypto.ops.sign(crypto.key, msgBuffer)); +// const tokenData = { +// msg: msgBuffer.toString('base64url'), +// sig: msgSig.toString('base64url'), +// }; +// const tokenJSON = JSON.stringify(tokenData); +// return Buffer.from(tokenJSON); +// } + +// async function validateToken( +// tokenBuffer: Buffer, +// peerHost: Host, +// crypto: QUICServerCrypto, +// ): Promise { +// let tokenData; +// try { +// tokenData = JSON.parse(tokenBuffer.toString()); +// } catch { +// return; +// } +// if (typeof tokenData !== 'object' || tokenData == null) { +// return; +// } +// if (typeof tokenData.msg !== 'string' || typeof tokenData.sig !== 'string') { +// return; +// } +// const msgBuffer = Buffer.from(tokenData.msg, 'base64url'); +// const msgSig = Buffer.from(tokenData.sig, 'base64url'); +// if (!(await crypto.ops.verify(crypto.key, msgBuffer, msgSig))) { +// return; +// } +// let msgData; +// try { +// msgData = JSON.parse(msgBuffer.toString()); +// } catch { +// return; +// } +// if (typeof msgData !== 'object' || msgData == null) { +// return; +// } +// if (typeof msgData.dcid !== 'string' || typeof msgData.host !== 'string') { +// return; +// } +// if (msgData.host !== peerHost) { +// return; +// } +// return QUICConnectionId.fromString(msgData.dcid); +// } + +async function sleep(ms: number): Promise { + return await new Promise((r) => setTimeout(r, ms)); +} + +function toPeerCertChain(peerCert: DetailedPeerCertificate): Array { + let currentCert = peerCert; + const visitedCerts: Set = new Set(); + while (currentCert != null && !visitedCerts.has(currentCert.raw)) { + visitedCerts.add(currentCert.raw); + currentCert = currentCert.issuerCertificate; + } + return [...visitedCerts]; +} + +/** + * Collects PEM arrays specified in `QUICConfig` into a PEM chain array. + * This can be used for keys, certs and ca. + */ +function collectPEMs( + pems?: string | Array | Uint8Array | Array, +): Array { + const pemsChain: Array = []; + if (typeof pems === 'string') { + pemsChain.push(pems.trim() + '\n'); + } else if (pems instanceof Uint8Array) { + pemsChain.push(textDecoder.decode(pems).trim() + '\n'); + } else if (Array.isArray(pems)) { + for (const c of pems) { + if (typeof c === 'string') { + pemsChain.push(c.trim() + '\n'); + } else { + pemsChain.push(textDecoder.decode(c).trim() + '\n'); + } + } + } + return pemsChain; +} + +/** + * Converts PEM strings to DER Uint8Array + */ +function pemToDER(pem: string): Uint8Array { + const pemB64 = pem + .replace(/-----BEGIN .*-----/, '') + .replace(/-----END .*-----/, '') + .replace(/\s+/g, ''); + const der = Buffer.from(pemB64, 'base64'); + return new Uint8Array(der); +} + +/** + * Converts DER Uint8Array to PEM string + */ +function derToPEM(der: Uint8Array): string { + const data = Buffer.from(der.buffer, der.byteOffset, der.byteLength); + const contents = + data + .toString('base64') + .replace(/(.{64})/g, '$1\n') + .trimEnd() + '\n'; + return `-----BEGIN CERTIFICATE-----\n${contents}-----END CERTIFICATE-----\n`; +} + +/** + * Formats error exceptions. + * Example: `Error: description - message` + */ +function formatError(error: Error): string { + return `${error.name}${ + 'description' in error ? `: ${error.description}` : '' + }${error.message !== undefined ? ` - ${error.message}` : ''}`; +} + +/** + * WebSocketConnection error/close codes + * sourced from: https://www.iana.org/assignments/websocket/websocket.xml + */ +const enum ConnectionErrorCode { + Normal = 1000, + GoingAway = 1001, + ProtocolError = 1002, + UnsupportedData = 1003, + NoStatusReceived = 1005, + AbnormalClosure = 1006, + InvalidFramePayloadData = 1007, + PolicyViolation = 1008, + MessageTooBig = 1009, + MandatoryExtension = 1010, + InternalServerError = 1011, + ServiceRestart = 1012, + TryAgainLater = 1013, + BadGateway = 1014, + TLSHandshake = 1015, +} + +export { + textEncoder, + textDecoder, + never, + isIPv4, + isIPv6, + isIPv4MappedIPv6, + isIPv4MappedIPv6Hex, + isIPv4MappedIPv6Dec, + toIPv4MappedIPv6Dec, + toIPv4MappedIPv6Hex, + fromIPv4MappedIPv6, + toCanonicalIp, + resolveHostname, + resolveHost, + isPort, + toPort, + promisify, + promise, + bufferWrap, + buildAddress, + resolvesZeroIP, + isHostWildcard, + sleep, + toPeerCertChain, + collectPEMs, + pemToDER, + derToPEM, + formatError, + ConnectionErrorCode, +}; diff --git a/tests/WebSocketClient.test.ts b/tests/WebSocketClient.test.ts new file mode 100644 index 00000000..ff5281a8 --- /dev/null +++ b/tests/WebSocketClient.test.ts @@ -0,0 +1,768 @@ +import type { KeyTypes } from './utils'; +import type WebSocketConnection from '@/WebSocketConnection'; +import Logger, { formatting, LogLevel, StreamHandler } from '@matrixai/logger'; +import { promise, pemToDER } from '@/utils'; +import * as events from '@/events'; +import * as errors from '@/errors'; +import WebSocketClient from '@/WebSocketClient'; +import WebSocketServer from '@/WebSocketServer'; +import * as testsUtils from './utils'; + +describe(WebSocketClient.name, () => { + const logger = new Logger(`${WebSocketClient.name} Test`, LogLevel.WARN, [ + new StreamHandler( + formatting.format`${formatting.level}:${formatting.keys}:${formatting.msg}`, + ), + ]); + const localhost = '127.0.0.1'; + const types: Array = ['RSA', 'ECDSA', 'ED25519']; + // Const types: Array = ['RSA']; + const defaultType = types[0]; + test('to ipv6 server succeeds', async () => { + const connectionEventProm = + promise(); + const tlsConfigServer = await testsUtils.generateConfig(defaultType); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigServer.key, + cert: tlsConfigServer.cert, + verifyPeer: false, + }, + }); + server.addEventListener( + events.EventWebSocketServerConnection.name, + (e: events.EventWebSocketServerConnection) => + connectionEventProm.resolveP(e), + ); + await server.start({ + host: '::1', + port: 0, + }); + const client = await WebSocketClient.createWebSocketClient({ + host: '::1', + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + }, + }); + const conn = (await connectionEventProm.p).detail; + expect(conn.localHost).toBe('::1'); + expect(conn.localPort).toBe(server.port); + expect(conn.remoteHost).toBe('::1'); + expect(conn.remotePort).toBe(client.connection.localPort); + await client.destroy(); + await server.stop(); + }); + test('to dual stack server succeeds', async () => { + const connectionEventProm = + promise(); + const tlsConfigServer = await testsUtils.generateConfig(defaultType); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigServer.key, + cert: tlsConfigServer.cert, + verifyPeer: false, + }, + }); + server.addEventListener( + events.EventWebSocketServerConnection.name, + (e: events.EventWebSocketServerConnection) => + connectionEventProm.resolveP(e), + ); + await server.start({ + host: '::', + port: 0, + }); + const client = await WebSocketClient.createWebSocketClient({ + host: '::', // Will resolve to ::1 + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + }, + }); + const conn = (await connectionEventProm.p).detail; + expect(conn.localHost).toBe('::1'); + expect(conn.localPort).toBe(server.port); + expect(conn.remoteHost).toBe('::1'); + expect(conn.remotePort).toBe(client.connection.localPort); + await client.destroy(); + await server.stop(); + }); + describe('hard connection failures', () => { + test('internal error when there is no server', async () => { + // WebSocketClient repeatedly dials until the connection timeout + await expect( + WebSocketClient.createWebSocketClient({ + host: localhost, + port: 56666, + logger: logger.getChild(WebSocketClient.name), + config: { + keepAliveTimeoutTime: 200, + verifyPeer: false, + }, + }), + ).rejects.toHaveProperty( + ['name'], + errors.ErrorWebSocketConnectionLocal.name, + ); + }); + test('client times out with ctx timer while starting', async () => { + const tlsConfigServer = await testsUtils.generateConfig(defaultType); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigServer.key, + cert: tlsConfigServer.cert, + verifyPeer: true, + verifyCallback: async () => { + await testsUtils.sleep(1000); + }, + }, + }); + await server.start({ + host: localhost, + port: 0, + }); + await expect( + WebSocketClient.createWebSocketClient( + { + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + }, + }, + { timer: 100 }, + ), + ).rejects.toThrow(errors.ErrorWebSocketClientCreateTimeOut); + await server.stop(); + }); + test('client times out with ctx signal while starting', async () => { + const abortController = new AbortController(); + const tlsConfigServer = await testsUtils.generateConfig(defaultType); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigServer.key, + cert: tlsConfigServer.cert, + verifyPeer: true, + verifyCallback: async () => { + await testsUtils.sleep(1000); + }, + }, + }); + await server.start({ + host: localhost, + port: 0, + }); + const clientProm = WebSocketClient.createWebSocketClient( + { + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + }, + }, + { signal: abortController.signal }, + ); + await testsUtils.sleep(100); + abortController.abort(Error('abort error')); + await expect(clientProm).rejects.toThrow(Error('abort error')); + await server.stop(); + }); + }); + describe.each(types)('TLS rotation with %s', (type) => { + test('existing connections config is unchanged and still function', async () => { + const tlsConfig1 = await testsUtils.generateConfig(type); + const tlsConfig2 = await testsUtils.generateConfig(type); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfig1.key, + cert: tlsConfig1.cert, + }, + }); + await server.start({ + host: localhost, + }); + const peerCertChainProm = promise>(); + const client1 = await WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: true, + verifyCallback: async (peerCertChain) => { + peerCertChainProm.resolveP(peerCertChain); + }, + }, + }); + server.updateConfig({ + key: tlsConfig2.key, + cert: tlsConfig2.cert, + }); + + const peerCertChainNew = await peerCertChainProm.p; + + expect(peerCertChainNew[0].buffer).toEqual( + pemToDER(tlsConfig1.cert).buffer, + ); + + await client1.destroy(); + await server.stop(); + }); + test('new connections use new config', async () => { + const tlsConfig1 = await testsUtils.generateConfig(type); + const tlsConfig2 = await testsUtils.generateConfig(type); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfig1.key, + cert: tlsConfig1.cert, + }, + }); + await server.start({ + host: localhost, + }); + const client1 = await WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: true, + verifyCallback: async () => {}, + }, + }); + server.updateConfig({ + key: tlsConfig2.key, + cert: tlsConfig2.cert, + }); + // Starting a new connection has a different peerCertChain + const peerCertChainProm = promise>(); + const client2 = await WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: true, + verifyCallback: async (peerCertChain) => { + peerCertChainProm.resolveP(peerCertChain); + }, + }, + }); + expect((await peerCertChainProm.p)[0].buffer).toEqual( + pemToDER(tlsConfig2.cert).buffer, + ); + await client1.destroy(); + await client2.destroy(); + await server.stop(); + }); + }); + describe.each(types)('graceful tls handshake with %s certs', (type) => { + test('server verification succeeds', async () => { + const tlsConfigs = await testsUtils.generateConfig(type); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigs.key, + cert: tlsConfigs.cert, + verifyPeer: false, + }, + }); + const handleConnectionEventProm = promise(); + server.addEventListener( + events.EventWebSocketServerConnection.name, + handleConnectionEventProm.resolveP, + ); + await server.start({ + host: localhost, + }); + // Connection should succeed + const client = await WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: true, + ca: tlsConfigs.ca, + }, + }); + await handleConnectionEventProm.p; + await client.destroy(); + await server.stop(); + }); + test('client verification succeeds', async () => { + const tlsConfigs1 = await testsUtils.generateConfig(type); + const tlsConfigs2 = await testsUtils.generateConfig(type); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigs1.key, + cert: tlsConfigs1.cert, + verifyPeer: true, + ca: tlsConfigs2.ca, + }, + }); + const handleConnectionEventProm = promise(); + server.addEventListener( + events.EventWebSocketServerConnection.name, + handleConnectionEventProm.resolveP, + ); + await server.start({ + host: localhost, + }); + // // Connection should succeed + const client = await WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + key: tlsConfigs2.key, + cert: tlsConfigs2.cert, + verifyPeer: false, + }, + }); + await client.destroy(); + await server.stop(); + }); + test('client and server verification succeeds', async () => { + const tlsConfigs1 = await testsUtils.generateConfig(type); + const tlsConfigs2 = await testsUtils.generateConfig(type); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigs1.key, + cert: tlsConfigs1.cert, + ca: tlsConfigs2.ca, + verifyPeer: true, + }, + }); + const handleConnectionEventProm = promise(); + server.addEventListener( + events.EventWebSocketServerConnection.name, + handleConnectionEventProm.resolveP, + ); + await server.start({ + host: localhost, + }); + // Connection should succeed + const client = await WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + key: tlsConfigs2.key, + cert: tlsConfigs2.cert, + ca: tlsConfigs1.ca, + verifyPeer: true, + }, + }); + await handleConnectionEventProm.p; + await client.destroy(); + await server.stop(); + }); + test('graceful failure verifying server', async () => { + const tlsConfigs1 = await testsUtils.generateConfig(type); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigs1.key, + cert: tlsConfigs1.cert, + verifyPeer: false, + }, + }); + await server.start({ + host: localhost, + }); + // Connection should fail + await expect( + WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: true, + }, + }), + ).rejects.toHaveProperty( + 'name', + errors.ErrorWebSocketConnectionLocalTLS.name, + ); + await server.stop(); + }); + test('graceful failure verifying client', async () => { + const tlsConfigs1 = await testsUtils.generateConfig(type); + const tlsConfigs2 = await testsUtils.generateConfig(type); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigs1.key, + cert: tlsConfigs1.cert, + verifyPeer: true, + }, + }); + await server.start({ + host: localhost, + }); + // Connection succeeds but peer will reject shortly after + await expect( + WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + key: tlsConfigs2.key, + cert: tlsConfigs2.cert, + verifyPeer: false, + }, + }), + ).rejects.toHaveProperty( + 'name', + errors.ErrorWebSocketConnectionPeer.name, + ); + + await server.stop(); + }); + test('graceful failure verifying client and server', async () => { + const tlsConfigs1 = await testsUtils.generateConfig(type); + const tlsConfigs2 = await testsUtils.generateConfig(type); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigs1.key, + cert: tlsConfigs1.cert, + verifyPeer: true, + }, + }); + await server.start({ + host: localhost, + }); + // Connection should fail + await expect( + WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + key: tlsConfigs2.key, + cert: tlsConfigs2.cert, + verifyPeer: true, + }, + }), + ).rejects.toHaveProperty( + 'name', + errors.ErrorWebSocketConnectionLocalTLS.name, + ); + + await server.stop(); + }); + }); + describe.each(types)('custom TLS verification with %s', (type) => { + test('server succeeds custom verification', async () => { + const tlsConfigs = await testsUtils.generateConfig(type); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigs.key, + cert: tlsConfigs.cert, + verifyPeer: false, + }, + }); + const handleConnectionEventProm = promise(); + server.addEventListener( + events.EventWebSocketServerConnection.name, + handleConnectionEventProm.resolveP, + ); + await server.start({ + host: localhost, + }); + // Connection should succeed + const verifyProm = promise | undefined>(); + const client = await WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: true, + verifyCallback: async (certs) => { + verifyProm.resolveP(certs); + }, + }, + }); + await handleConnectionEventProm.p; + await expect(verifyProm.p).toResolve(); + await client.destroy(); + await server.stop(); + }); + test('server fails custom verification', async () => { + const tlsConfigs = await testsUtils.generateConfig(type); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigs.key, + cert: tlsConfigs.cert, + verifyPeer: false, + }, + }); + const handleConnectionEventProm = promise(); + server.addEventListener( + events.EventWebSocketServerConnection.name, + (event: events.EventWebSocketServerConnection) => + handleConnectionEventProm.resolveP(event.detail), + ); + await server.start({ + host: localhost, + }); + // Connection should fail + const clientProm = WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: true, + verifyCallback: () => { + throw Error('SOME ERROR'); + }, + }, + }); + clientProm.catch(() => {}); + + // Verification by peer happens after connection is securely established and started + const serverConn = await handleConnectionEventProm.p; + const serverErrorProm = promise(); + serverConn.addEventListener( + events.EventWebSocketConnectionError.name, + (evt: events.EventWebSocketConnectionError) => + serverErrorProm.rejectP(evt.detail), + ); + await expect(serverErrorProm.p).rejects.toThrow( + errors.ErrorWebSocketConnectionPeer, + ); + await expect(clientProm).rejects.toThrow( + errors.ErrorWebSocketConnectionLocal, + ); + + await server.stop(); + }); + test('client succeeds custom verification', async () => { + const tlsConfigs = await testsUtils.generateConfig(type); + const verifyProm = promise | undefined>(); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigs.key, + cert: tlsConfigs.cert, + verifyPeer: true, + verifyCallback: async (certs) => { + verifyProm.resolveP(certs); + }, + }, + }); + const handleConnectionEventProm = promise(); + server.addEventListener( + events.EventWebSocketServerConnection.name, + handleConnectionEventProm.resolveP, + ); + await server.start({ + host: localhost, + }); + // Connection should succeed + const client = await WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + key: tlsConfigs.key, + cert: tlsConfigs.cert, + }, + }); + await handleConnectionEventProm.p; + await expect(verifyProm.p).toResolve(); + await client.destroy(); + await server.stop(); + }); + test('client fails custom verification', async () => { + const tlsConfigs = await testsUtils.generateConfig(type); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigs.key, + cert: tlsConfigs.cert, + verifyPeer: true, + verifyCallback: () => { + throw Error('SOME ERROR'); + }, + }, + }); + const handleConnectionEventProm = promise(); + server.addEventListener( + events.EventWebSocketServerConnection.name, + (event: events.EventWebSocketServerConnection) => + handleConnectionEventProm.resolveP(event.detail), + ); + await server.start({ + host: localhost, + port: 55555, + }); + // Connection should fail + await expect( + WebSocketClient.createWebSocketClient({ + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + key: tlsConfigs.key, + cert: tlsConfigs.cert, + verifyPeer: false, + }, + }), + ).rejects.toHaveProperty('name', 'ErrorWebSocketConnectionPeer'); + + // // Server connection is never emitted + await Promise.race([ + handleConnectionEventProm.p.then(() => { + throw Error('Server connection should not be emitted'); + }), + // Allow some time + testsUtils.sleep(200), + ]); + + await server.stop(); + }); + }); + test('Connections are established and secured quickly', async () => { + const tlsConfigServer = await testsUtils.generateConfig(defaultType); + + const connectionEventProm = + promise(); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfigServer.key, + cert: tlsConfigServer.cert, + verifyPeer: false, + }, + }); + server.addEventListener( + events.EventWebSocketServerConnection.name, + (e: events.EventWebSocketServerConnection) => + connectionEventProm.resolveP(e), + ); + await server.start({ + host: localhost, + port: 55555, + }); + // If the server is slow to respond then this will time out. + // Then main cause of this was the server not processing the initial packet + // that creates the `WebSocketConnection`, as a result, the whole creation waited + // an extra 1 second for the client to retry the initial packet. + const client = await WebSocketClient.createWebSocketClient( + { + host: localhost, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + }, + }, + { timer: 500 }, + ); + await connectionEventProm.p; + await client.destroy({ force: true }); + await server.stop({ force: true }); + }); + // Test('socket stopping first triggers client destruction', async () => { + // const tlsConfigServer = await testsUtils.generateConfig(defaultType); + + // const connectionEventProm = promise(); + // const server = new WebSocketServer({ + // logger: logger.getChild(WebSocketServer.name), + // config: { + // key: tlsConfigServer.key, + // cert: tlsConfigServer.cert, + // verifyPeer: false, + // }, + // }); + // server.addEventListener( + // events.EventWebSocketServerConnection.name, + // (e: events.EventWebSocketServerConnection) => connectionEventProm.resolveP(e.detail), + // ); + // await server.start({ + // host: localhost, + // port: 55555, + // }); + // // If the server is slow to respond then this will time out. + // // Then main cause of this was the server not processing the initial packet + // // that creates the `WebSocketConnection`, as a result, the whole creation waited + // // an extra 1 second for the client to retry the initial packet. + // const client = await WebSocketClient.createWebSocketClient( + // { + // host: localhost, + // port: server.port, + // logger: logger.getChild(WebSocketClient.name), + // config: { + // verifyPeer: false, + // }, + // }); + + // const serverConnection = await connectionEventProm.p; + // // handling server connection error event + // const serverConnectionErrorProm = promise(); + // serverConnection.addEventListener( + // events.EventWebSocketConnectionError.name, + // (evt: events.EventWebSocketConnectionError) => serverConnectionErrorProm.rejectP(evt.detail), + // {once: true}, + // ); + + // // Handling client connection error event + // const clientConnectionErrorProm = promise(); + // client.connection.addEventListener( + // events.EventWebSocketConnectionError.name, + // (evt: events.EventWebSocketConnectionError) => clientConnectionErrorProm.rejectP(evt.detail), + // {once: true}, + // ); + + // // handling client destroy event + // const clientConnectionStoppedProm = promise(); + // client.connection.addEventListener( + // events.EventWebSocketConnectionStopped.name, + // () => clientConnectionStoppedProm.resolveP(), + // {once: true}, + // ); + + // // handling client error event + // const clientErrorProm = promise(); + // client.addEventListener( + // events.EventWebSocketClientError.name, + // (evt: events.EventWebSocketClientError) => clientErrorProm.rejectP(evt.detail), + // {once: true}, + // ); + + // // handling client destroy event + // const clientDestroyedProm = promise(); + // client.addEventListener( + // events.EventWebSocketClientDestroyed.name, + // () => clientDestroyedProm.resolveP(), + // {once: true}, + // ); + + // // Socket failure triggers client connection local failure + // await expect(clientConnectionErrorProm.p).rejects.toThrow(errors.ErrorWebSocketConnectionLocal); + // await expect(clientErrorProm.p).rejects.toThrow(errors.ErrorWebSocketClientSocketNotRunning); + // await clientDestroyedProm.p; + // await clientConnectionStoppedProm.p; + + // // Socket failure will not trigger any close frame since transport has failed so server connection will time out + // await expect(serverConnectionErrorProm.p).rejects.toThrow(errors.ErrorWebSocketConnectionIdleTimeout); + + // await client.destroy({ force: true }); + // await server.stop({ force: true }); + // }) +}); diff --git a/tests/WebSocketConnection.test.ts b/tests/WebSocketConnection.test.ts new file mode 100644 index 00000000..624c36b1 --- /dev/null +++ b/tests/WebSocketConnection.test.ts @@ -0,0 +1,367 @@ +import Logger, { formatting, LogLevel, StreamHandler } from '@matrixai/logger'; +import { startStop } from '@matrixai/async-init'; +import * as events from '@/events'; +import * as errors from '@/errors'; +import WebSocketClient from '@/WebSocketClient'; +import WebSocketServer from '@/WebSocketServer'; +import * as utils from '@/utils'; +import WebSocketConnection from '@/WebSocketConnection'; +import * as testsUtils from './utils'; + +describe(WebSocketConnection.name, () => { + const logger = new Logger(WebSocketConnection.name, LogLevel.WARN, [ + new StreamHandler( + formatting.format`${formatting.level}:${formatting.keys}:${formatting.msg}`, + ), + ]); + + const localhost = '127.0.0.1'; + + describe('closing connection', () => { + let tlsConfig: testsUtils.TLSConfigs; + beforeEach(async () => { + tlsConfig = await testsUtils.generateConfig('RSA'); + }); + test('handles a connection and closes before message', async () => { + const server = new WebSocketServer({ + config: tlsConfig, + logger, + }); + await server.start({ host: localhost }); + + const client = await WebSocketClient.createWebSocketClient({ + host: server.host, + port: server.port, + logger, + config: { + verifyPeer: false, + }, + }); + + const stream = await client.connection.newStream(); + + const reader = stream.readable.getReader(); + + await server.stop({ force: true }); + + await expect(reader.read()).toReject(); + }); + + test('connection dispatches correct close event', async () => { + const connectionProm = utils.promise(); + + const server = new WebSocketServer({ + config: tlsConfig, + logger, + }); + await server.start({ host: localhost }); + + server.addEventListener( + events.EventWebSocketServerConnection.name, + () => { + connectionProm.resolveP(); + }, + ); + + const client = await WebSocketClient.createWebSocketClient({ + host: server.host, + port: server.port, + logger, + config: { + verifyPeer: false, + }, + }); + + const closeProm = utils.promise(); + + client.connection.addEventListener( + events.EventWebSocketConnectionClose.name, + closeProm.resolveP as any, + ); + + await client.destroy(); + + const closeDetail = (await closeProm.p).detail; + + expect(closeDetail.data.errorCode).toBe(utils.ConnectionErrorCode.Normal); + + await server.stop(); + }); + }); + + describe('keepalive', () => { + let tlsConfig: testsUtils.TLSConfigs; + beforeEach(async () => { + tlsConfig = await testsUtils.generateConfig('RSA'); + }); + test('connection can time out on client', async () => { + const connectionEventProm = utils.promise(); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfig.key, + cert: tlsConfig.cert, + verifyPeer: false, + keepAliveIntervalTime: 1000, + keepAliveTimeoutTime: Infinity, + }, + }); + server.addEventListener( + events.EventWebSocketServerConnection.name, + (e: events.EventWebSocketServerConnection) => + connectionEventProm.resolveP(e.detail), + ); + await server.start({ + host: localhost, + }); + const client = await WebSocketClient.createWebSocketClient({ + host: '::ffff:127.0.0.1', + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + keepAliveTimeoutTime: 100, + keepAliveIntervalTime: Infinity, + }, + }); + const clientConnection = client.connection; + const clientTimeoutProm = utils.promise(); + clientConnection.addEventListener( + events.EventWebSocketConnectionError.name, + (event: events.EventWebSocketConnectionError) => { + if ( + event.detail instanceof + errors.ErrorWebSocketConnectionKeepAliveTimeOut + ) { + clientTimeoutProm.resolveP(); + } + }, + ); + await clientTimeoutProm.p; + const serverConnection = await connectionEventProm.p; + await testsUtils.sleep(100); + // Server and client has cleaned up + expect(clientConnection[startStop.running]).toBeFalse(); + expect(serverConnection[startStop.running]).toBeFalse(); + + await client.destroy(); + await server.stop(); + }); + test('connection can time out on server', async () => { + const connectionEventProm = utils.promise(); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfig.key, + cert: tlsConfig.cert, + verifyPeer: false, + keepAliveTimeoutTime: 100, + keepAliveIntervalTime: Infinity, + }, + }); + server.addEventListener( + events.EventWebSocketServerConnection.name, + (e: events.EventWebSocketServerConnection) => + connectionEventProm.resolveP(e.detail), + ); + await server.start({ + host: localhost, + }); + const client = await WebSocketClient.createWebSocketClient({ + host: '::ffff:127.0.0.1', + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + keepAliveIntervalTime: 1000, + keepAliveTimeoutTime: Infinity, + }, + }); + // Setting no keepalive should cause the connection to time out + // It has cleaned up due to timeout + const clientConnection = client.connection; + const serverConnection = await connectionEventProm.p; + const serverTimeoutProm = utils.promise(); + serverConnection.addEventListener( + events.EventWebSocketConnectionError.name, + (evt: events.EventWebSocketConnectionError) => { + if ( + evt.detail instanceof + errors.ErrorWebSocketConnectionKeepAliveTimeOut + ) { + serverTimeoutProm.resolveP(); + } + }, + ); + await serverTimeoutProm.p; + await testsUtils.sleep(100); + // Server and client has cleaned up + expect(clientConnection[startStop.running]).toBeFalse(); + expect(serverConnection[startStop.running]).toBeFalse(); + + await client.destroy(); + await server.stop(); + }); + test('keep alive prevents timeout on client', async () => { + const connectionEventProm = utils.promise(); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfig.key, + cert: tlsConfig.cert, + verifyPeer: false, + keepAliveTimeoutTime: 20000, + }, + }); + server.addEventListener( + events.EventWebSocketServerConnection.name, + (e: events.EventWebSocketServerConnection) => + connectionEventProm.resolveP(e.detail), + ); + await server.start({ + host: localhost, + }); + const client = await WebSocketClient.createWebSocketClient({ + host: '::ffff:127.0.0.1', + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + keepAliveTimeoutTime: 100, + keepAliveIntervalTime: 50, + }, + }); + const clientConnection = client.connection; + const clientTimeoutProm = utils.promise(); + clientConnection.addEventListener( + events.EventWebSocketConnectionStream.name, + (event: events.EventWebSocketConnectionError) => { + if ( + event.detail instanceof + errors.ErrorWebSocketConnectionKeepAliveTimeOut + ) { + clientTimeoutProm.resolveP(); + } + }, + ); + await connectionEventProm.p; + // Connection would time out after 100ms if keep alive didn't work + await Promise.race([ + testsUtils.sleep(300), + clientTimeoutProm.p.then(() => { + throw Error('Connection timed out'); + }), + ]); + await client.destroy(); + await server.stop(); + }); + test('keep alive prevents timeout on server', async () => { + const connectionEventProm = utils.promise(); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfig.key, + cert: tlsConfig.cert, + verifyPeer: false, + keepAliveTimeoutTime: 100, + keepAliveIntervalTime: 50, + }, + }); + server.addEventListener( + events.EventWebSocketServerConnection.name, + (e: events.EventWebSocketServerConnection) => + connectionEventProm.resolveP(e.detail), + ); + await server.start({ + host: localhost, + }); + const client = await WebSocketClient.createWebSocketClient({ + host: '::ffff:127.0.0.1', + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + keepAliveTimeoutTime: 20000, + }, + }); + // Setting no keepalive should cause the connection to time out + // It has cleaned up due to timeout + const serverConnection = await connectionEventProm.p; + const serverTimeoutProm = utils.promise(); + serverConnection.addEventListener( + events.EventWebSocketConnectionStream.name, + (event: events.EventWebSocketConnectionError) => { + if ( + event.detail instanceof + errors.ErrorWebSocketConnectionKeepAliveTimeOut + ) { + serverTimeoutProm.resolveP(); + } + }, + ); + // Connection would time out after 100ms if keep alive didn't work + await Promise.race([ + testsUtils.sleep(300), + serverTimeoutProm.p.then(() => { + throw Error('Connection timed out'); + }), + ]); + await client.destroy(); + await server.stop(); + }); + test('client keep alive prevents timeout on server', async () => { + const connectionEventProm = utils.promise(); + const server = new WebSocketServer({ + logger: logger.getChild(WebSocketServer.name), + config: { + key: tlsConfig.key, + cert: tlsConfig.cert, + verifyPeer: false, + keepAliveTimeoutTime: 100, + }, + }); + server.addEventListener( + events.EventWebSocketServerConnection.name, + (e: events.EventWebSocketServerConnection) => + connectionEventProm.resolveP(e.detail), + ); + await server.start({ + host: localhost, + }); + const client = await WebSocketClient.createWebSocketClient({ + host: '::ffff:127.0.0.1', + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + keepAliveTimeoutTime: 20000, + keepAliveIntervalTime: 50, + }, + }); + // Setting no keepalive should cause the connection to time out + // It has cleaned up due to timeout + const serverConnection = await connectionEventProm.p; + const serverTimeoutProm = utils.promise(); + serverConnection.addEventListener( + events.EventWebSocketConnectionStream.name, + (event: events.EventWebSocketConnectionError) => { + if ( + event.detail instanceof + errors.ErrorWebSocketConnectionKeepAliveTimeOut + ) { + serverTimeoutProm.resolveP(); + } + }, + ); + // Connection would time out after 100ms if keep alive didn't work + await Promise.race([ + testsUtils.sleep(300), + serverTimeoutProm.p.then(() => { + throw Error('Connection timed out'); + }), + ]); + await client.destroy(); + await server.stop(); + }); + }); +}); diff --git a/tests/WebSocketServer.test.ts b/tests/WebSocketServer.test.ts new file mode 100644 index 00000000..3a6032e7 --- /dev/null +++ b/tests/WebSocketServer.test.ts @@ -0,0 +1,314 @@ +import type { X509Certificate } from '@peculiar/x509'; +import type { Host } from '@/types'; +import Logger, { LogLevel, StreamHandler, formatting } from '@matrixai/logger'; +import { startStop } from '@matrixai/async-init'; +import WebSocketServer from '@/WebSocketServer'; +import * as utils from '@/utils'; +import * as events from '@/events'; +import WebSocketClient from '@/WebSocketClient'; +import * as testsUtils from './utils'; + +describe(WebSocketServer.name, () => { + const logger = new Logger(`${WebSocketServer.name} Test`, LogLevel.WARN, [ + new StreamHandler( + formatting.format`${formatting.level}:${formatting.keys}:${formatting.msg}`, + ), + ]); + let keyPairRSA: { + publicKey: JsonWebKey; + privateKey: JsonWebKey; + }; + let certRSA: X509Certificate; + let keyPairRSAPEM: { + publicKey: string; + privateKey: string; + }; + let certRSAPEM: string; + let keyPairECDSA: { + publicKey: JsonWebKey; + privateKey: JsonWebKey; + }; + let certECDSA: X509Certificate; + let keyPairECDSAPEM: { + publicKey: string; + privateKey: string; + }; + let certECDSAPEM: string; + let keyPairEd25519: { + publicKey: JsonWebKey; + privateKey: JsonWebKey; + }; + let certEd25519: X509Certificate; + let keyPairEd25519PEM: { + publicKey: string; + privateKey: string; + }; + let certEd25519PEM: string; + beforeAll(async () => { + keyPairRSA = await testsUtils.generateKeyPairRSA(); + certRSA = await testsUtils.generateCertificate({ + certId: '0', + subjectKeyPair: keyPairRSA, + issuerPrivateKey: keyPairRSA.privateKey, + duration: 60 * 60 * 24 * 365 * 10, + }); + keyPairRSAPEM = await testsUtils.keyPairRSAToPEM(keyPairRSA); + certRSAPEM = testsUtils.certToPEM(certRSA); + keyPairECDSA = await testsUtils.generateKeyPairECDSA(); + certECDSA = await testsUtils.generateCertificate({ + certId: '0', + subjectKeyPair: keyPairECDSA, + issuerPrivateKey: keyPairECDSA.privateKey, + duration: 60 * 60 * 24 * 365 * 10, + }); + keyPairECDSAPEM = await testsUtils.keyPairECDSAToPEM(keyPairECDSA); + certECDSAPEM = testsUtils.certToPEM(certECDSA); + keyPairEd25519 = await testsUtils.generateKeyPairEd25519(); + certEd25519 = await testsUtils.generateCertificate({ + certId: '0', + subjectKeyPair: keyPairEd25519, + issuerPrivateKey: keyPairEd25519.privateKey, + duration: 60 * 60 * 24 * 365 * 10, + }); + keyPairEd25519PEM = await testsUtils.keyPairEd25519ToPEM(keyPairEd25519); + certEd25519PEM = testsUtils.certToPEM(certEd25519); + }); + // This has to be setup asynchronously due to key generation + let key: ArrayBuffer; + beforeEach(async () => { + key = await testsUtils.generateKeyHMAC(); + }); + + describe('start and stop', () => { + test('with RSA', async () => { + const webSocketServer = new WebSocketServer({ + config: { + key: keyPairRSAPEM.privateKey, + cert: certRSAPEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await webSocketServer.start(); + // Default to dual-stack + expect(webSocketServer.host).toBe('::'); + expect(typeof webSocketServer.port).toBe('number'); + await webSocketServer.stop(); + }); + test('with ECDSA', async () => { + const webSocketServer = new WebSocketServer({ + config: { + key: keyPairECDSAPEM.privateKey, + cert: certECDSAPEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await webSocketServer.start(); + // Default to dual-stack + expect(webSocketServer.host).toBe('::'); + expect(typeof webSocketServer.port).toBe('number'); + await webSocketServer.stop(); + }); + test('with Ed25519', async () => { + const webSocketServer = new WebSocketServer({ + config: { + key: keyPairEd25519PEM.privateKey, + cert: certEd25519PEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await webSocketServer.start(); + // Default to dual-stack + expect(webSocketServer.host).toBe('::'); + expect(typeof webSocketServer.port).toBe('number'); + await webSocketServer.stop(); + }); + }); + describe('binding to host and port', () => { + test('listen on IPv4', async () => { + const webSocketServer = new WebSocketServer({ + config: { + key: keyPairEd25519PEM.privateKey, + cert: certEd25519PEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await webSocketServer.start({ + host: '127.0.0.1', + }); + expect(webSocketServer.host).toBe('127.0.0.1'); + expect(typeof webSocketServer.port).toBe('number'); + await webSocketServer.stop(); + }); + test('listen on IPv6', async () => { + const webSocketServer = new WebSocketServer({ + config: { + key: keyPairEd25519PEM.privateKey, + cert: certEd25519PEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await webSocketServer.start({ + host: '::1', + }); + expect(webSocketServer.host).toBe('::1'); + expect(typeof webSocketServer.port).toBe('number'); + await webSocketServer.stop(); + }); + test('listen on dual stack', async () => { + const webSocketServer = new WebSocketServer({ + config: { + key: keyPairEd25519PEM.privateKey, + cert: certEd25519PEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await webSocketServer.start({ + host: '::', + }); + expect(webSocketServer.host).toBe('::'); + expect(typeof webSocketServer.port).toBe('number'); + await webSocketServer.stop(); + }); + test('listen on IPv4 mapped IPv6', async () => { + // NOT RECOMMENDED, because send addresses will have to be mapped + // addresses, which means you can ONLY connect to mapped addresses + let webSocketServer = new WebSocketServer({ + config: { + key: keyPairEd25519PEM.privateKey, + cert: certEd25519PEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await webSocketServer.start({ + host: '::ffff:127.0.0.1', + }); + expect(webSocketServer.host).toBe('::ffff:127.0.0.1'); + expect(typeof webSocketServer.port).toBe('number'); + await webSocketServer.stop(); + webSocketServer = new WebSocketServer({ + config: { + key: keyPairEd25519PEM.privateKey, + cert: certEd25519PEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await webSocketServer.start({ + host: '::ffff:7f00:1', + }); + // Will resolve to dotted-decimal variant + expect(webSocketServer.host).toBe('::ffff:127.0.0.1'); + expect(typeof webSocketServer.port).toBe('number'); + await webSocketServer.stop(); + }); + test('listen on hostname', async () => { + const webSocketServer = new WebSocketServer({ + config: { + key: keyPairEd25519PEM.privateKey, + cert: certEd25519PEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await webSocketServer.start({ + host: 'localhost', + }); + // Default to using dns lookup, which uses the OS DNS resolver + const host = await utils.resolveHostname('localhost'); + expect(webSocketServer.host).toBe(host); + expect(typeof webSocketServer.port).toBe('number'); + await webSocketServer.stop(); + }); + test('listen on hostname and custom resolver', async () => { + const webSocketServer = new WebSocketServer({ + config: { + key: keyPairEd25519PEM.privateKey, + cert: certEd25519PEM, + }, + resolveHostname: () => '127.0.0.1' as Host, + logger: logger.getChild(WebSocketServer.name), + }); + await webSocketServer.start({ + host: 'abcdef', + }); + expect(webSocketServer.host).toBe('127.0.0.1'); + expect(typeof webSocketServer.port).toBe('number'); + await webSocketServer.stop(); + }); + }); + describe('stops on internal server failure', () => { + test('handles https server failure', async () => { + const server = new WebSocketServer({ + config: { + key: keyPairEd25519PEM.privateKey, + cert: certEd25519PEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await server.start({ host: '::' }); + + const closeP = utils.promise(); + // @ts-ignore: protected property + server.server.close(() => { + closeP.resolveP(); + }); + await closeP.p; + + // The webSocketServer should stop itself + expect(server[startStop.status]).toBe(null); + }); + test('handles WebSocket server failure', async () => { + const server = new WebSocketServer({ + config: { + key: keyPairEd25519PEM.privateKey, + cert: certEd25519PEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await server.start({ host: '::' }); + + const closeP = utils.promise(); + // @ts-ignore: protected property + server.webSocketServer.close(() => { + closeP.resolveP(); + }); + await closeP.p; + + // The WebSocketServer should stop itself + expect(server[startStop.status]).toBe(null); + }); + }); + test('handles multiple connections', async () => { + const conns = 10; + let serverConns = 0; + + const server = new WebSocketServer({ + config: { + key: keyPairEd25519PEM.privateKey, + cert: certEd25519PEM, + }, + logger: logger.getChild(WebSocketServer.name), + }); + await server.start({ host: '::' }); + + server.addEventListener(events.EventWebSocketServerConnection.name, () => { + serverConns++; + }); + + const clients: Array = []; + for (let i = 0; i < conns; i++) { + const client = await WebSocketClient.createWebSocketClient({ + host: server.host, + port: server.port, + logger: logger.getChild(WebSocketClient.name), + config: { + verifyPeer: false, + }, + }); + + await client.connection.newStream(); + + clients.push(client); + } + expect(serverConns).toBe(conns); + await server.stop({ force: true }); + }); +}); diff --git a/tests/WebSocketStream.test.ts b/tests/WebSocketStream.test.ts new file mode 100644 index 00000000..f78be34d --- /dev/null +++ b/tests/WebSocketStream.test.ts @@ -0,0 +1,637 @@ +import type { StreamId } from '@/message'; +import Logger, { formatting, LogLevel, StreamHandler } from '@matrixai/logger'; +import { fc, testProp } from '@fast-check/jest'; +import WebSocketStream from '@/WebSocketStream'; +import WebSocketConnection from '@/WebSocketConnection'; +import * as events from '@/events'; +import * as utils from '@/utils'; +import * as messageUtils from '@/message/utils'; +import { StreamMessageType } from '@/message'; +import * as messageTestUtils from './message/utils'; + +type StreamOptions = Partial[0]>; + +// Smaller buffer size for the sake of testing +const STREAM_BUFFER_SIZE = 64; + +const logger1 = new Logger('stream 1', LogLevel.WARN, [ + new StreamHandler( + formatting.format`${formatting.level}:${formatting.keys}:${formatting.msg}`, + ), +]); + +const logger2 = new Logger('stream 2', LogLevel.WARN, [ + new StreamHandler( + formatting.format`${formatting.level}:${formatting.keys}:${formatting.msg}`, + ), +]); + +let streamIdCounter = 0n; + +jest.mock('@/WebSocketConnection', () => { + return jest.fn().mockImplementation((streamOptions: StreamOptions = {}) => { + const instance = new EventTarget() as EventTarget & { + peerConnection: WebSocketConnection | undefined; + connectTo: (connection: WebSocketConnection) => void; + send: (data: Uint8Array) => Promise; + newStream: () => Promise; + streamMap: Map; + }; + instance.peerConnection = undefined; + instance.connectTo = (peerConnection: any) => { + instance.peerConnection = peerConnection; + peerConnection.peerConnection = instance; + }; + instance.streamMap = new Map(); + instance.newStream = async () => { + const stream = new WebSocketStream({ + initiated: 'local', + streamId: streamIdCounter as StreamId, + bufferSize: STREAM_BUFFER_SIZE, + connection: instance as any, + logger: logger1, + ...streamOptions, + }); + stream.addEventListener( + events.EventWebSocketStreamSend.name, + async (evt: any) => { + await instance.send(evt.msg); + }, + ); + stream.addEventListener( + events.EventWebSocketStreamStopped.name, + () => { + instance.streamMap.delete(stream.streamId); + }, + { once: true }, + ); + instance.streamMap.set(stream.streamId, stream); + await stream.start(); + streamIdCounter++; + return stream; + }; + instance.send = async (array: Uint8Array | Array) => { + let data: Uint8Array; + if (ArrayBuffer.isView(array)) { + data = array; + } else { + data = messageUtils.concatUInt8Array(...array); + } + const { data: streamId, remainder } = messageUtils.parseStreamId(data); + // @ts-ignore: protected property + let stream = instance.peerConnection!.streamMap.get(streamId); + if (stream == null) { + if ( + !(remainder.at(0) === 0 && remainder.at(1) === StreamMessageType.Ack) + ) { + return; + } + stream = new WebSocketStream({ + initiated: 'peer', + streamId, + bufferSize: STREAM_BUFFER_SIZE, + connection: instance.peerConnection!, + logger: logger2, + ...streamOptions, + }); + stream.addEventListener( + events.EventWebSocketStreamSend.name, + async (evt: any) => { + // @ts-ignore: protected property + await instance.peerConnection!.send(evt.msg); + }, + ); + stream.addEventListener( + events.EventWebSocketStreamStopped.name, + () => { + // @ts-ignore: protected property + instance.peerConnection!.streamMap.delete(streamId); + }, + { once: true }, + ); + // @ts-ignore: protected property + instance.peerConnection!.streamMap.set(stream.streamId, stream); + await stream.start(); + instance.peerConnection!.dispatchEvent( + new events.EventWebSocketConnectionStream({ + detail: stream, + }), + ); + } + await stream.streamRecv(remainder); + }; + return instance; + }); +}); + +const connectionMock = jest.mocked(WebSocketConnection, true); + +describe(WebSocketStream.name, () => { + beforeEach(async () => { + connectionMock.mockClear(); + }); + + async function createConnectionPair( + streamOptions: StreamOptions = {}, + ): Promise<[WebSocketConnection, WebSocketConnection]> { + const connection1 = new (WebSocketConnection as any)(streamOptions); + const connection2 = new (WebSocketConnection as any)(streamOptions); + (connection1 as any).connectTo(connection2); + return [connection1, connection2]; + } + + async function createStreamPairFrom( + connection1: WebSocketConnection, + connection2: WebSocketConnection, + ): Promise<[WebSocketStream, WebSocketStream]> { + const stream1 = await connection1.newStream(); + const createStream2Prom = utils.promise(); + connection2.addEventListener( + events.EventWebSocketConnectionStream.name, + (e: events.EventWebSocketConnectionStream) => { + createStream2Prom.resolveP(e.detail); + }, + { once: true }, + ); + const stream2 = await createStream2Prom.p; + return [stream1, stream2]; + } + + async function createStreamPair(streamOptions: StreamOptions = {}) { + const [connection1, connection2] = await createConnectionPair( + streamOptions, + ); + return createStreamPairFrom(connection1, connection2); + } + + test('should create stream', async () => { + const streams = await createStreamPair(); + expect(streams.length).toEqual(2); + for (const stream of streams) { + await stream.stop(); + } + }); + test('destroying stream should clean up on both ends while streams are used', async () => { + const [connection1, connection2] = await createConnectionPair(); + const streamsNum = 10; + + let streamCreatedCount = 0; + let streamEndedCount = 0; + const streamCreationProm = utils.promise(); + const streamEndedProm = utils.promise(); + + const streams = new Array(); + + connection2.addEventListener( + events.EventWebSocketConnectionStream.name, + (event: events.EventWebSocketConnectionStream) => { + const stream = event.detail; + streamCreatedCount += 1; + if (streamCreatedCount >= streamsNum) streamCreationProm.resolveP(); + stream.addEventListener( + events.EventWebSocketStreamStopped.name, + () => { + streamEndedCount += 1; + if (streamEndedCount >= streamsNum) streamEndedProm.resolveP(); + }, + { once: true }, + ); + }, + ); + + for (let i = 0; i < streamsNum; i++) { + const stream = await connection1.newStream(); + streams.push(stream); + } + await streamCreationProm.p; + await Promise.allSettled(streams.map((stream) => stream.stop())); + await streamEndedProm.p; + expect(streamCreatedCount).toEqual(streamsNum); + expect(streamEndedCount).toEqual(streamsNum); + + for (const stream of streams) { + await stream.stop(); + } + }); + test('should propagate errors over stream for writable', async () => { + const testReason = Symbol('TestReason'); + const codeToReason = (type, code: bigint) => { + switch (code) { + case 4002n: + return testReason; + default: + return new Error(`${type.toString()} ${code.toString()}`); + } + }; + const reasonToCode = (type, reason) => { + if (reason === testReason) return 4002n; + return 0n; + }; + const [stream1, stream2] = await createStreamPair({ + codeToReason, + reasonToCode, + }); + + const stream1Readable = stream1.readable; + const stream2Writable = stream2.writable; + await stream2Writable.abort(testReason); + await expect(stream1Readable.getReader().read()).rejects.toBe(testReason); + await expect(stream2Writable.getWriter().write()).rejects.toBe(testReason); + }); + testProp( + 'should send data over stream - single write within buffer size', + [messageTestUtils.fcBuffer({ maxLength: STREAM_BUFFER_SIZE })], + async (data) => { + const [stream1, stream2] = await createStreamPair(); + + const stream1Readable = stream1.readable; + const stream2Writable = stream2.writable; + await stream1.writable.close(); + + const writer = stream2Writable.getWriter(); + const reader = stream1Readable.getReader(); + + const writeF = async () => { + await writer.write(data); + await writer.close(); + }; + + const readChunks: Array = []; + const readF = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + readChunks.push(value); + } + }; + + await Promise.all([writeF(), readF()]); + + expect(messageUtils.concatUInt8Array(...readChunks)).toEqual( + messageUtils.concatUInt8Array(data), + ); + + await stream1.stop(); + await stream2.stop(); + }, + ); + testProp( + 'should send data over stream - single write outside buffer size', + [messageTestUtils.fcBuffer({ minLength: STREAM_BUFFER_SIZE + 1 })], + async (data) => { + const [stream1, stream2] = await createStreamPair(); + + const stream1Readable = stream1.readable; + const stream2Writable = stream2.writable; + await stream1.writable.close(); + + const writer = stream2Writable.getWriter(); + const reader = stream1Readable.getReader(); + + const writeF = async () => { + await writer.write(data); + await writer.close(); + }; + + const readChunks: Array = []; + const readF = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + readChunks.push(value); + } + }; + + await Promise.all([writeF(), readF()]); + + expect(messageUtils.concatUInt8Array(...readChunks)).toEqual(data); + + await stream1.stop(); + await stream2.stop(); + }, + ); + testProp( + 'should send data over stream - multiple writes within buffer size', + [fc.array(messageTestUtils.fcBuffer({ maxLength: STREAM_BUFFER_SIZE }))], + async (data) => { + const [stream1, stream2] = await createStreamPair(); + + const stream1Readable = stream1.readable; + const stream2Writable = stream2.writable; + await stream1.writable.close(); + + const writer = stream2Writable.getWriter(); + const reader = stream1Readable.getReader(); + + const writeF = async () => { + for (const chunk of data) { + await writer.write(chunk); + } + await writer.close(); + }; + + const readChunks: Array = []; + const readProm = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + readChunks.push(value); + } + }; + + await Promise.all([writeF(), readProm()]); + + expect(messageUtils.concatUInt8Array(...readChunks)).toEqual( + messageUtils.concatUInt8Array(...data), + ); + + await stream1.stop(); + await stream2.stop(); + }, + ); + testProp( + 'should send data over stream - multiple writes outside buffer size', + [ + fc.array( + messageTestUtils.fcBuffer({ minLength: STREAM_BUFFER_SIZE + 1 }), + ), + ], + async (data) => { + const [stream1, stream2] = await createStreamPair(); + + const stream1Readable = stream1.readable; + const stream2Writable = stream2.writable; + await stream1.writable.close(); + + const writer = stream2Writable.getWriter(); + const reader = stream1Readable.getReader(); + + const writeF = async () => { + for (const chunk of data) { + await writer.write(chunk); + } + await writer.close(); + }; + + const readChunks: Array = []; + const readF = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + readChunks.push(value); + } + }; + + await Promise.all([writeF(), readF()]); + + expect(messageUtils.concatUInt8Array(...readChunks)).toEqual( + messageUtils.concatUInt8Array(...data), + ); + + await stream1.stop(); + await stream2.stop(); + }, + ); + testProp( + 'should send data over stream - multiple writes within and outside buffer size', + [ + fc.array( + fc.oneof( + messageTestUtils.fcBuffer({ minLength: STREAM_BUFFER_SIZE + 1 }), + messageTestUtils.fcBuffer({ maxLength: STREAM_BUFFER_SIZE }), + ), + ), + ], + async (data) => { + const [stream1, stream2] = await createStreamPair(); + + const stream1Readable = stream1.readable; + const stream2Writable = stream2.writable; + await stream1.writable.close(); + + const writer = stream2Writable.getWriter(); + const reader = stream1Readable.getReader(); + + const writeF = async () => { + for (const chunk of data) { + await writer.write(chunk); + } + await writer.close(); + }; + + const readChunks: Array = []; + const readF = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + readChunks.push(value); + } + }; + + await Promise.all([writeF(), readF()]); + + expect(messageUtils.concatUInt8Array(...readChunks)).toEqual( + messageUtils.concatUInt8Array(...data), + ); + + await stream1.stop(); + await stream2.stop(); + }, + ); + testProp( + 'should send data over stream - simultaneous multiple writes within and outside buffer size', + [ + fc.array( + fc.oneof( + messageTestUtils.fcBuffer({ minLength: STREAM_BUFFER_SIZE + 1 }), + messageTestUtils.fcBuffer({ maxLength: STREAM_BUFFER_SIZE }), + ), + ), + fc.array( + fc.oneof( + messageTestUtils.fcBuffer({ minLength: STREAM_BUFFER_SIZE + 1 }), + messageTestUtils.fcBuffer({ maxLength: STREAM_BUFFER_SIZE }), + ), + ), + ], + async (...data) => { + const streams = await createStreamPair(); + + const readProms: Array>> = []; + const writeProms: Array> = []; + + for (const [i, stream] of streams.entries()) { + const reader = stream.readable.getReader(); + const writer = stream.writable.getWriter(); + const writeF = async () => { + for (const chunk of data[i]) { + await writer.write(chunk); + } + await writer.close(); + }; + const readF = async () => { + const readChunks: Array = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + readChunks.push(value); + } + return readChunks; + }; + readProms.push(readF()); + writeProms.push(writeF()); + } + await Promise.all(writeProms); + const readResults = await Promise.all(readProms); + + data.reverse(); + for (const [i, readResult] of readResults.entries()) { + expect(messageUtils.concatUInt8Array(...readResult)).toEqual( + messageUtils.concatUInt8Array(...data[i]), + ); + } + + for (const stream of streams) { + await stream.stop(); + } + }, + ); + test('streams can be cancelled after data sent', async () => { + const cancelReason = Symbol('CancelReason'); + const codeToReason = (type, code: bigint) => { + switch (code) { + case 4001n: + return cancelReason; + default: + return new Error(`${type.toString()} ${code.toString()}`); + } + }; + const reasonToCode = (_type, reason) => { + if (reason === cancelReason) return 4001n; + return 0n; + }; + const [_stream1, stream2] = await createStreamPair({ + codeToReason, + reasonToCode, + }); + + const writer = stream2.writable.getWriter(); + await writer.write(new Uint8Array(2)); + writer.releaseLock(); + stream2.cancel(cancelReason); + + await expect(stream2.readable.getReader().read()).rejects.toBe( + cancelReason, + ); + await expect(stream2.writable.getWriter().write()).rejects.toBe( + cancelReason, + ); + }); + test('streams can be cancelled with no data sent', async () => { + const cancelReason = Symbol('CancelReason'); + const codeToReason = (type, code: bigint) => { + switch (code) { + case 4001n: + return cancelReason; + default: + return new Error(`${type.toString()} ${code.toString()}`); + } + }; + const reasonToCode = (_type, reason) => { + if (reason === cancelReason) return 4001n; + return 0n; + }; + const [_stream1, stream2] = await createStreamPair({ + codeToReason, + reasonToCode, + }); + + stream2.cancel(cancelReason); + + await expect(stream2.readable.getReader().read()).rejects.toBe( + cancelReason, + ); + await expect(stream2.writable.getWriter().write()).rejects.toBe( + cancelReason, + ); + }); + test('streams can be cancelled concurrently after data sent', async () => { + const cancelReason = Symbol('CancelReason'); + const codeToReason = (type, code: bigint) => { + switch (code) { + case 4001n: + return cancelReason; + default: + return new Error(`${type.toString()} ${code.toString()}`); + } + }; + const reasonToCode = (_type, reason) => { + if (reason === cancelReason) return 4001n; + return 0n; + }; + const [stream1, stream2] = await createStreamPair({ + codeToReason, + reasonToCode, + }); + + const writer = stream2.writable.getWriter(); + await writer.write(Buffer.alloc(STREAM_BUFFER_SIZE - 1)); + writer.releaseLock(); + + void stream1.cancel(cancelReason); + void stream2.cancel(cancelReason); + + await expect(stream2.readable.getReader().read()).rejects.toBe( + cancelReason, + ); + await expect(stream2.writable.getWriter().write()).rejects.toBe( + cancelReason, + ); + await expect(stream1.readable.getReader().read()).rejects.toBe( + cancelReason, + ); + await expect(stream1.writable.getWriter().write()).rejects.toBe( + cancelReason, + ); + }); + test('stream will end when waiting for more data', async () => { + // Needed to check that the pull based reading of data doesn't break when we + // temporarily run out of data to read + const [stream1, stream2] = await createStreamPair(); + const message = Buffer.alloc(STREAM_BUFFER_SIZE - 1); + const clientWriter = stream1.writable.getWriter(); + await clientWriter.write(message); + + // Drain the readable buffer + const serverReader = stream2.readable.getReader(); + serverReader.releaseLock(); + + // Closing stream with no buffered data should be responsive + await clientWriter.close(); + await stream2.writable.close(); + + // Both streams are destroyed even without reading till close + await Promise.all([stream1.closedP, stream2.closedP]); + }); + test('stream can error when blocked on data', async () => { + // This checks that if the readable web-stream is full and not pulling data, + // we will still respond to an error in the readable stream + + const [stream1, stream2] = await createStreamPair(); + + const message = new Uint8Array(STREAM_BUFFER_SIZE); + + const stream1Writer = stream1.writable.getWriter(); + await stream1Writer.write(message); + + // Fill up buffers to block reads from pulling + const stream2Writer = stream2.writable.getWriter(); + await stream2Writer.write(message); + await stream2Writer.write(message); + + await stream1Writer.abort(new Error('Some Error')); + await stream2Writer.abort(new Error('Some Error')); + + await Promise.all([stream1.closedP, stream2.closedP]); + }); +}); diff --git a/tests/WebSocketStreamQueue.test.ts b/tests/WebSocketStreamQueue.test.ts new file mode 100644 index 00000000..95740fe1 --- /dev/null +++ b/tests/WebSocketStreamQueue.test.ts @@ -0,0 +1,32 @@ +import { fc, testProp } from '@fast-check/jest'; +import WebSocketStreamQueue from '@/WebSocketStreamQueue'; + +describe(WebSocketStreamQueue.name, () => { + testProp('should queue items', [fc.array(fc.uint8Array())], (array) => { + const queue = new WebSocketStreamQueue(); + let totalLength = 0; + let totalByteLength = 0; + for (const buffer of array) { + queue.queue(buffer); + totalByteLength += buffer.byteLength; + totalLength += buffer.length; + } + expect(queue.count).toBe(array.length); + expect(queue.byteLength).toBe(totalByteLength); + expect(queue.length).toBe(totalLength); + }); + testProp('should dequeue items', [fc.array(fc.uint8Array())], (array) => { + const queue = new WebSocketStreamQueue(); + for (const buffer of array) { + queue.queue(buffer); + } + const result: Array = []; + for (let i = 0; i < array.length; i++) { + result.push(queue.dequeue()!); + } + expect(result).toEqual(array); + expect(queue.count).toBe(0); + expect(queue.byteLength).toBe(0); + expect(queue.length).toBe(0); + }); +}); diff --git a/tests/message/utils.test.ts b/tests/message/utils.test.ts new file mode 100644 index 00000000..a4dfd6bb --- /dev/null +++ b/tests/message/utils.test.ts @@ -0,0 +1,113 @@ +import type { ConnectionMessage, StreamMessage } from '@/message'; +import { testProp } from '@fast-check/jest'; +import { + generateConnectionMessage, + generateStreamId, + generateStreamMessage, + generateStreamMessageAckPayload, + generateStreamMessageClosePayload, + generateStreamMessageErrorPayload, + generateStreamMessageType, + generateVarInt, + parseConnectionMessage, + parseStreamId, + parseStreamMessage, + parseStreamMessageAckPayload, + parseStreamMessageClosePayload, + parseStreamMessageErrorPayload, + parseStreamMessageType, + parseVarInt, +} from '@/message'; +import { + connectionMessageArb, + streamIdArb, + streamMessageAckPayloadArb, + streamMessageArb, + streamMessageClosePayloadArb, + streamMessageErrorPayloadArb, + streamMessageTypeArb, + varIntArb, +} from './utils'; + +describe('StreamMessage', () => { + testProp('should parse/generate VarInt', [varIntArb], (varInt) => { + const parsedVarInt = parseVarInt(generateVarInt(varInt)); + expect(parsedVarInt.data).toBe(varInt); + expect(parsedVarInt.remainder).toHaveLength(0); + }); + testProp('should parse/generate StreamId', [streamIdArb], (streamId) => { + const parsedStreamId = parseStreamId(generateStreamId(streamId)); + expect(parsedStreamId.data).toBe(streamId); + expect(parsedStreamId.remainder).toHaveLength(0); + }); + testProp( + 'should parse/generate StreamMessageType', + [streamMessageTypeArb], + (streamMessageType) => { + const parsedStreamMessageType = parseStreamMessageType( + generateStreamMessageType(streamMessageType), + ); + expect(parsedStreamMessageType.data).toBe(streamMessageType); + expect(parsedStreamMessageType.remainder).toHaveLength(0); + }, + ); + testProp( + 'should parse/generate StreamMessageAckPayload', + [streamMessageAckPayloadArb], + (ackPayload) => { + const parsedAckPayload = parseStreamMessageAckPayload( + generateStreamMessageAckPayload(ackPayload), + ); + expect(parsedAckPayload.data).toBe(ackPayload); + expect(parsedAckPayload.remainder).toHaveLength(0); + }, + ); + testProp( + 'should parse/generate StreamMessageClosePayload', + [streamMessageClosePayloadArb], + (closePayload) => { + const parsedClosePayload = parseStreamMessageClosePayload( + generateStreamMessageClosePayload(closePayload), + ); + expect(parsedClosePayload.data).toBe(closePayload); + expect(parsedClosePayload.remainder).toHaveLength(0); + }, + ); + testProp( + 'should parse/generate StreamMessageErrorPayload', + [streamMessageErrorPayloadArb], + (errorPayload) => { + const parsedClosePayload = parseStreamMessageErrorPayload( + generateStreamMessageErrorPayload(errorPayload), + ); + expect(parsedClosePayload.data).toEqual(errorPayload); + expect(parsedClosePayload.remainder).toHaveLength(0); + }, + ); + testProp( + 'should parse/generate StreamMessage', + [streamMessageArb], + (streamMessage) => { + const generatedStreamMessage = generateStreamMessage( + streamMessage as StreamMessage, + ); + const parsedStreamMessage = parseStreamMessage(generatedStreamMessage); + expect(parsedStreamMessage.payload).toEqual(streamMessage.payload); + }, + ); + testProp( + 'should parse/generate ConnectionMessage', + [connectionMessageArb], + (connectionMessage) => { + const generatedConnectionMessage = generateConnectionMessage( + connectionMessage as ConnectionMessage, + ); + const parsedConnectionMessage = parseConnectionMessage( + generatedConnectionMessage, + ); + expect(parsedConnectionMessage.payload).toEqual( + connectionMessage.payload, + ); + }, + ); +}); diff --git a/tests/message/utils.ts b/tests/message/utils.ts new file mode 100644 index 00000000..293684a0 --- /dev/null +++ b/tests/message/utils.ts @@ -0,0 +1,92 @@ +import type { StreamId, VarInt } from '@/message'; +import { fc } from '@fast-check/jest'; +import { StreamMessageType, StreamShutdown } from '@/message'; + +function fcBuffer(contraints?: fc.IntArrayConstraints) { + return fc.uint8Array(contraints).map((data) => { + const buff = Buffer.allocUnsafe(data.length); + buff.set(data); + return buff; + }); +} + +const varIntArb = fc.bigInt({ + min: 0n, + max: 2n ** 62n - 1n, +}) as fc.Arbitrary; + +const streamIdArb = varIntArb as fc.Arbitrary; + +const streamShutdownArb = fc.constantFrom( + StreamShutdown.Read, + StreamShutdown.Write, +); + +const streamMessageTypeArb = fc.constantFrom( + StreamMessageType.Ack, + StreamMessageType.Data, + StreamMessageType.Close, + StreamMessageType.Error, +); + +const streamMessageAckPayloadArb = fc.integer({ min: 0, max: 2 ** 32 - 1 }); + +const streamMessageClosePayloadArb = streamShutdownArb; + +const streamMessageErrorPayloadArb = fc.record({ + shutdown: streamShutdownArb, + code: varIntArb, +}); + +const streamMessageAckArb = fc.record({ + type: fc.constant(StreamMessageType.Ack), + payload: streamMessageAckPayloadArb, +}); + +const streamMessageDataArb = fc.record({ + type: fc.constant(StreamMessageType.Data), + payload: fcBuffer(), +}); + +const streamMessageCloseArb = fc.record({ + type: fc.constant(StreamMessageType.Close), + payload: streamMessageClosePayloadArb, +}); + +const streamMessageErrorArb = fc.record({ + type: fc.constant(StreamMessageType.Error), + payload: streamMessageErrorPayloadArb, +}); + +const streamMessageArb = fc.oneof( + streamMessageAckArb, + streamMessageDataArb, + streamMessageCloseArb, + streamMessageErrorArb, +); + +const connectionMessageArb = streamIdArb.chain((streamId) => { + return streamMessageArb.map((streamMessage) => { + return { + streamId, + ...streamMessage, + }; + }); +}); + +export { + fcBuffer, + varIntArb, + streamIdArb, + streamShutdownArb, + streamMessageTypeArb, + streamMessageAckPayloadArb, + streamMessageClosePayloadArb, + streamMessageErrorPayloadArb, + streamMessageAckArb, + streamMessageDataArb, + streamMessageCloseArb, + streamMessageErrorArb, + streamMessageArb, + connectionMessageArb, +}; diff --git a/tests/utils.ts b/tests/utils.ts index 800a02c5..fb538644 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,7 +1,815 @@ +import type { X509Certificate } from '@peculiar/x509'; +import type WebSocketStream from '@/WebSocketStream'; +import type { StreamCodeToReason, StreamReasonToCode } from '@/types'; +import * as peculiarWebcrypto from '@peculiar/webcrypto'; +import * as x509 from '@peculiar/x509'; +import * as ws from 'ws'; +import fc from 'fast-check'; + +/** + * WebCrypto polyfill from @peculiar/webcrypto + * This behaves differently with respect to Ed25519 keys + * See: https://github.com/PeculiarVentures/webcrypto/issues/55 + */ +const webcrypto = new peculiarWebcrypto.Crypto(); + +x509.cryptoProvider.set(webcrypto); + async function sleep(ms: number): Promise { return await new Promise((r) => setTimeout(r, ms)); } +async function yieldMicro(): Promise { + return await new Promise((r) => queueMicrotask(r)); +} + +async function randomBytes(data: ArrayBuffer) { + webcrypto.getRandomValues(new Uint8Array(data)); +} + +/** + * Generates RSA keypair + */ +async function generateKeyPairRSA(): Promise<{ + publicKey: JsonWebKey; + privateKey: JsonWebKey; +}> { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'], + ); + return { + publicKey: await webcrypto.subtle.exportKey('jwk', keyPair.publicKey), + privateKey: await webcrypto.subtle.exportKey('jwk', keyPair.privateKey), + }; +} + +/** + * Generates ECDSA keypair + */ +async function generateKeyPairECDSA(): Promise<{ + publicKey: JsonWebKey; + privateKey: JsonWebKey; +}> { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'ECDSA', + namedCurve: 'P-256', + }, + true, + ['sign', 'verify'], + ); + return { + publicKey: await webcrypto.subtle.exportKey('jwk', keyPair.publicKey), + privateKey: await webcrypto.subtle.exportKey('jwk', keyPair.privateKey), + }; +} + +/** + * Generates Ed25519 keypair + * This uses `@peculiar/webcrypto` API + */ +async function generateKeyPairEd25519(): Promise<{ + publicKey: JsonWebKey; + privateKey: JsonWebKey; +}> { + const keyPair = (await webcrypto.subtle.generateKey( + { + name: 'EdDSA', + namedCurve: 'Ed25519', + }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair; + return { + publicKey: await webcrypto.subtle.exportKey('jwk', keyPair.publicKey), + privateKey: await webcrypto.subtle.exportKey('jwk', keyPair.privateKey), + }; +} + +/** + * Imports public key. + * This uses `@peculiar/webcrypto` API for Ed25519 keys. + */ +async function importPublicKey(publicKey: JsonWebKey): Promise { + let algorithm; + switch (publicKey.kty) { + case 'RSA': + switch (publicKey.alg) { + case 'RS256': + algorithm = { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }; + break; + case 'RS384': + algorithm = { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-384', + }; + break; + case 'RS512': + algorithm = { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-512', + }; + break; + default: + throw new Error(`Unsupported algorithm ${publicKey.alg}`); + } + break; + case 'EC': + switch (publicKey.crv) { + case 'P-256': + algorithm = { + name: 'ECDSA', + namedCurve: 'P-256', + }; + break; + case 'P-384': + algorithm = { + name: 'ECDSA', + namedCurve: 'P-384', + }; + break; + case 'P-521': + algorithm = { + name: 'ECDSA', + namedCurve: 'P-521', + }; + break; + default: + throw new Error(`Unsupported curve ${publicKey.crv}`); + } + break; + case 'OKP': + algorithm = { + name: 'EdDSA', + namedCurve: 'Ed25519', + }; + break; + default: + throw new Error(`Unsupported key type ${publicKey.kty}`); + } + return await webcrypto.subtle.importKey('jwk', publicKey, algorithm, true, [ + 'verify', + ]); +} + +/** + * Imports private key. + * This uses `@peculiar/webcrypto` API for Ed25519 keys. + */ +async function importPrivateKey(privateKey: JsonWebKey): Promise { + let algorithm; + switch (privateKey.kty) { + case 'RSA': + switch (privateKey.alg) { + case 'RS256': + algorithm = { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }; + break; + case 'RS384': + algorithm = { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-384', + }; + break; + case 'RS512': + algorithm = { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-512', + }; + break; + default: + throw new Error(`Unsupported algorithm ${privateKey.alg}`); + } + break; + case 'EC': + switch (privateKey.crv) { + case 'P-256': + algorithm = { + name: 'ECDSA', + namedCurve: 'P-256', + }; + break; + case 'P-384': + algorithm = { + name: 'ECDSA', + namedCurve: 'P-384', + }; + break; + case 'P-521': + algorithm = { + name: 'ECDSA', + namedCurve: 'P-521', + }; + break; + default: + throw new Error(`Unsupported curve ${privateKey.crv}`); + } + break; + case 'OKP': + algorithm = { + name: 'EdDSA', + namedCurve: 'Ed25519', + }; + break; + default: + throw new Error(`Unsupported key type ${privateKey.kty}`); + } + return await webcrypto.subtle.importKey('jwk', privateKey, algorithm, true, [ + 'sign', + ]); +} + +async function keyPairRSAToPEM(keyPair: { + publicKey: JsonWebKey; + privateKey: JsonWebKey; +}): Promise<{ + publicKey: string; + privateKey: string; +}> { + const publicKey = await importPublicKey(keyPair.publicKey); + const privatekey = await importPrivateKey(keyPair.privateKey); + const publicKeySPKI = await webcrypto.subtle.exportKey('spki', publicKey); + const publicKeySPKIBuffer = Buffer.from(publicKeySPKI); + const publicKeyPEMBody = + publicKeySPKIBuffer + .toString('base64') + .replace(/(.{64})/g, '$1\n') + .trimEnd() + '\n'; + const publicKeyPEM = `-----BEGIN PUBLIC KEY-----\n${publicKeyPEMBody}\n-----END PUBLIC KEY-----\n`; + const privateKeyPKCS8 = await webcrypto.subtle.exportKey('pkcs8', privatekey); + const privateKeyPKCS8Buffer = Buffer.from(privateKeyPKCS8); + const privateKeyPEMBody = + privateKeyPKCS8Buffer + .toString('base64') + .replace(/(.{64})/g, '$1\n') + .trimEnd() + '\n'; + const privateKeyPEM = `-----BEGIN PRIVATE KEY-----\n${privateKeyPEMBody}-----END PRIVATE KEY-----\n`; + return { + publicKey: publicKeyPEM, + privateKey: privateKeyPEM, + }; +} + +async function keyPairECDSAToPEM(keyPair: { + publicKey: JsonWebKey; + privateKey: JsonWebKey; +}): Promise<{ + publicKey: string; + privateKey: string; +}> { + const publicKey = await importPublicKey(keyPair.publicKey); + const privatekey = await importPrivateKey(keyPair.privateKey); + const publicKeySPKI = await webcrypto.subtle.exportKey('spki', publicKey); + const publicKeySPKIBuffer = Buffer.from(publicKeySPKI); + const publicKeyPEMBody = + publicKeySPKIBuffer + .toString('base64') + .replace(/(.{64})/g, '$1\n') + .trimEnd() + '\n'; + const publicKeyPEM = `-----BEGIN PUBLIC KEY-----\n${publicKeyPEMBody}\n-----END PUBLIC KEY-----\n`; + const privateKeyPKCS8 = await webcrypto.subtle.exportKey('pkcs8', privatekey); + const privateKeyPKCS8Buffer = Buffer.from(privateKeyPKCS8); + const privateKeyPEMBody = + privateKeyPKCS8Buffer + .toString('base64') + .replace(/(.{64})/g, '$1\n') + .trimEnd() + '\n'; + const privateKeyPEM = `-----BEGIN PRIVATE KEY-----\n${privateKeyPEMBody}-----END PRIVATE KEY-----\n`; + return { + publicKey: publicKeyPEM, + privateKey: privateKeyPEM, + }; +} + +async function keyPairEd25519ToPEM(keyPair: { + publicKey: JsonWebKey; + privateKey: JsonWebKey; +}): Promise<{ + publicKey: string; + privateKey: string; +}> { + const publicKey = await importPublicKey(keyPair.publicKey); + const privatekey = await importPrivateKey(keyPair.privateKey); + const publicKeySPKI = await webcrypto.subtle.exportKey('spki', publicKey); + const publicKeySPKIBuffer = Buffer.from(publicKeySPKI); + const publicKeyPEMBody = + publicKeySPKIBuffer + .toString('base64') + .replace(/(.{64})/g, '$1\n') + .trimEnd() + '\n'; + const publicKeyPEM = `-----BEGIN PUBLIC KEY-----\n${publicKeyPEMBody}\n-----END PUBLIC KEY-----\n`; + const privateKeyPKCS8 = await webcrypto.subtle.exportKey('pkcs8', privatekey); + const privateKeyPKCS8Buffer = Buffer.from(privateKeyPKCS8); + const privateKeyPEMBody = + privateKeyPKCS8Buffer + .toString('base64') + .replace(/(.{64})/g, '$1\n') + .trimEnd() + '\n'; + const privateKeyPEM = `-----BEGIN PRIVATE KEY-----\n${privateKeyPEMBody}-----END PRIVATE KEY-----\n`; + return { + publicKey: publicKeyPEM, + privateKey: privateKeyPEM, + }; +} + +const extendedKeyUsageFlags = { + serverAuth: '1.3.6.1.5.5.7.3.1', + clientAuth: '1.3.6.1.5.5.7.3.2', + codeSigning: '1.3.6.1.5.5.7.3.3', + emailProtection: '1.3.6.1.5.5.7.3.4', + timeStamping: '1.3.6.1.5.5.7.3.8', + ocspSigning: '1.3.6.1.5.5.7.3.9', +}; + +/** + * Generate x509 certificate. + * Duration is in seconds. + * X509 certificates currently use `UTCTime` format for `notBefore` and `notAfter`. + * This means: + * - Only second resolution. + * - Minimum date for validity is 1970-01-01T00:00:00Z (inclusive). + * - Maximum date for valdity is 2049-12-31T23:59:59Z (inclusive). + */ +async function generateCertificate({ + certId, + subjectKeyPair, + issuerPrivateKey, + duration, + subjectAttrsExtra = [], + issuerAttrsExtra = [], + now = new Date(), +}: { + certId: string; + subjectKeyPair: { + publicKey: JsonWebKey; + privateKey: JsonWebKey; + }; + issuerPrivateKey: JsonWebKey; + duration: number; + subjectAttrsExtra?: Array<{ [key: string]: Array }>; + issuerAttrsExtra?: Array<{ [key: string]: Array }>; + now?: Date; +}): Promise { + const certIdNum = parseInt(certId); + const iss = certIdNum === 0 ? certIdNum : certIdNum - 1; + const sub = certIdNum; + const subjectPublicCryptoKey = await importPublicKey( + subjectKeyPair.publicKey, + ); + const subjectPrivateCryptoKey = await importPrivateKey( + subjectKeyPair.privateKey, + ); + const issuerPrivateCryptoKey = await importPrivateKey(issuerPrivateKey); + if (duration < 0) { + throw new RangeError('`duration` must be positive'); + } + // X509 `UTCTime` format only has resolution of seconds + // this truncates to second resolution + const notBeforeDate = new Date(now.getTime() - (now.getTime() % 1000)); + const notAfterDate = new Date(now.getTime() - (now.getTime() % 1000)); + // If the duration is 0, then only the `now` is valid + notAfterDate.setSeconds(notAfterDate.getSeconds() + duration); + if (notBeforeDate < new Date(0)) { + throw new RangeError( + '`notBeforeDate` cannot be before 1970-01-01T00:00:00Z', + ); + } + if (notAfterDate > new Date(new Date('2050').getTime() - 1)) { + throw new RangeError('`notAfterDate` cannot be after 2049-12-31T23:59:59Z'); + } + const serialNumber = certId; + // The entire subject attributes and issuer attributes + // is constructed via `x509.Name` class + // By default this supports on a limited set of names: + // CN, L, ST, O, OU, C, DC, E, G, I, SN, T + // If custom names are desired, this needs to change to constructing + // `new x509.Name('FOO=BAR', { FOO: '1.2.3.4' })` manually + // And each custom attribute requires a registered OID + // Because the OID is what is encoded into ASN.1 + const subjectAttrs = [ + { + CN: [`${sub}`], + }, + // Filter out conflicting CN attributes + ...subjectAttrsExtra.filter((attr) => !('CN' in attr)), + ]; + const issuerAttrs = [ + { + CN: [`${iss}`], + }, + // Filter out conflicting CN attributes + ...issuerAttrsExtra.filter((attr) => !('CN' in attr)), + ]; + const signingAlgorithm: any = issuerPrivateCryptoKey.algorithm; + if (signingAlgorithm.name === 'ECDSA') { + // In ECDSA, the signature should match the curve strength + switch (signingAlgorithm.namedCurve) { + case 'P-256': + signingAlgorithm.hash = 'SHA-256'; + break; + case 'P-384': + signingAlgorithm.hash = 'SHA-384'; + break; + case 'P-521': + signingAlgorithm.hash = 'SHA-512'; + break; + default: + throw new TypeError( + `Issuer private key has an unsupported curve: ${signingAlgorithm.namedCurve}`, + ); + } + } + const certConfig = { + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + subject: subjectAttrs, + issuer: issuerAttrs, + signingAlgorithm, + publicKey: subjectPublicCryptoKey, + signingKey: subjectPrivateCryptoKey, + extensions: [ + new x509.SubjectAlternativeNameExtension([ + { + type: 'ip', + value: '127.0.0.1', + }, + { + type: 'ip', + value: '::1', + }, + ]), + new x509.BasicConstraintsExtension(true, undefined, true), + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.nonRepudiation | + x509.KeyUsageFlags.keyAgreement | + x509.KeyUsageFlags.keyEncipherment | + x509.KeyUsageFlags.dataEncipherment, + true, + ), + new x509.ExtendedKeyUsageExtension([ + extendedKeyUsageFlags.serverAuth, + extendedKeyUsageFlags.clientAuth, + extendedKeyUsageFlags.codeSigning, + extendedKeyUsageFlags.emailProtection, + extendedKeyUsageFlags.timeStamping, + extendedKeyUsageFlags.ocspSigning, + ]), + await x509.SubjectKeyIdentifierExtension.create(subjectPublicCryptoKey), + ] as Array, + }; + certConfig.signingKey = issuerPrivateCryptoKey; + return await x509.X509CertificateGenerator.create(certConfig); +} + +function certToPEM(cert: X509Certificate): string { + return cert.toString('pem') + '\n'; +} + +/** + * Generate 256-bit HMAC key using webcrypto. + * Web Crypto prefers using the `CryptoKey` type. + * But to be fully generic, we use the `ArrayBuffer` type. + * In production, prefer to use libsodium as it would be faster. + */ +async function generateKeyHMAC(): Promise { + const cryptoKey = await webcrypto.subtle.generateKey( + { + name: 'HMAC', + hash: 'SHA-256', + }, + true, + ['sign', 'verify'], + ); + const key = await webcrypto.subtle.exportKey('raw', cryptoKey); + return key; +} + +/** + * Signs using the 256-bit HMAC key + * Web Crypto has to use the `CryptoKey` type. + * But to be fully generic, we use the `ArrayBuffer` type. + * In production, prefer to use libsodium as it would be faster. + */ +async function signHMAC(key: ArrayBuffer, data: ArrayBuffer) { + const cryptoKey = await webcrypto.subtle.importKey( + 'raw', + key, + { + name: 'HMAC', + hash: 'SHA-256', + }, + true, + ['sign', 'verify'], + ); + return webcrypto.subtle.sign('HMAC', cryptoKey, data); +} + +/** + * Verifies using 256-bit HMAC key + * Web Crypto prefers using the `CryptoKey` type. + * But to be fully generic, we use the `ArrayBuffer` type. + * In production, prefer to use libsodium as it would be faster. + */ +async function verifyHMAC( + key: ArrayBuffer, + data: ArrayBuffer, + sig: ArrayBuffer, +) { + const cryptoKey = await webcrypto.subtle.importKey( + 'raw', + key, + { + name: 'HMAC', + hash: 'SHA-256', + }, + true, + ['sign', 'verify'], + ); + return webcrypto.subtle.verify('HMAC', cryptoKey, sig, data); +} + +/** + * Zero-copy wraps ArrayBuffer-like objects into Buffer + * This supports ArrayBuffer, TypedArrays and the NodeJS Buffer + */ +function bufferWrap( + array: BufferSource, + offset?: number, + length?: number, +): Buffer { + if (Buffer.isBuffer(array)) { + return array; + } else if (ArrayBuffer.isView(array)) { + return Buffer.from( + array.buffer, + offset ?? array.byteOffset, + length ?? array.byteLength, + ); + } else { + return Buffer.from(array, offset, length); + } +} + +const bufferArb = (constraints?: fc.IntArrayConstraints) => { + return fc.uint8Array(constraints).map(bufferWrap); +}; + +type Messages = Array; + +type StreamData = { + messages: Messages; + startDelay: number; + endDelay: number; + delays: Array; +}; + +/** + * This is used to have a stream run concurrently in the background. + * Will resolve once stream has completed. + * This will send the data provided with delays provided. + * Will consume stream with provided delays between reads. + */ +const handleStreamProm = async ( + stream: WebSocketStream, + streamData: StreamData, +) => { + const messages = streamData.messages; + const delays = streamData.delays; + const writeProm = (async () => { + // Write data + let count = 0; + const writer = stream.writable.getWriter(); + for (const message of messages) { + await writer.write(message); + await sleep(delays[count % delays.length]); + count += 1; + } + await sleep(streamData.endDelay); + await writer.close(); + })(); + const readProm = (async () => { + // Consume readable + let count = 0; + for await (const _ of stream.readable) { + // Do nothing with delay, + await sleep(delays[count % delays.length]); + count += 1; + } + })(); + try { + await Promise.all([writeProm, readProm]); + } finally { + await stream.stop().catch(() => {}); + // @ts-ignore: kidnap logger + const streamLogger = stream.logger; + streamLogger.info( + `stream result ${JSON.stringify( + await Promise.allSettled([readProm, writeProm]), + )}`, + ); + } +}; + +/** + * Creates a formatted string listing the connection state. + */ +function wsStats(webSocket: ws.WebSocket, label: string) { + return ` +----${label}---- +established: ${webSocket.readyState === ws.OPEN}, +closing: ${webSocket.readyState === ws.CLOSING}, +closed: ${webSocket.readyState === ws.CLOSED}, +binary type: ${webSocket.binaryType}, +buffered amount: ${webSocket.bufferedAmount}, +url: ${webSocket.url}, +protocol: ${webSocket.protocol}, +`; +} + +type KeyTypes = 'RSA' | 'ECDSA' | 'ED25519'; +type TLSConfigs = { + key: string; + cert: string; + ca: string; +}; + +async function generateConfig(type: KeyTypes): Promise { + let privateKeyPem: string; + let keysLeaf: { publicKey: JsonWebKey; privateKey: JsonWebKey }; + let keysCa: { publicKey: JsonWebKey; privateKey: JsonWebKey }; + switch (type) { + case 'RSA': + { + keysLeaf = await generateKeyPairRSA(); + keysCa = await generateKeyPairRSA(); + privateKeyPem = (await keyPairRSAToPEM(keysLeaf)).privateKey; + } + break; + case 'ECDSA': + { + keysLeaf = await generateKeyPairECDSA(); + keysCa = await generateKeyPairECDSA(); + privateKeyPem = (await keyPairECDSAToPEM(keysLeaf)).privateKey; + } + break; + case 'ED25519': + { + keysLeaf = await generateKeyPairEd25519(); + keysCa = await generateKeyPairEd25519(); + privateKeyPem = (await keyPairEd25519ToPEM(keysLeaf)).privateKey; + } + break; + } + const certCa = await generateCertificate({ + certId: '0', + duration: 100000, + issuerPrivateKey: keysCa.privateKey, + subjectKeyPair: keysCa, + }); + const certLeaf = await generateCertificate({ + certId: '1', + duration: 100000, + issuerPrivateKey: keysCa.privateKey, + subjectKeyPair: keysLeaf, + }); + return { + key: privateKeyPem, + cert: certToPEM(certLeaf), + ca: certToPEM(certCa), + }; +} + +async function generateTLSConfig(type: 'RSA' | 'ECDSA' | 'Ed25519'): Promise<{ + leafKeyPair: { publicKey: JsonWebKey; privateKey: JsonWebKey }; + leafKeyPairPEM: { publicKey: string; privateKey: string }; + leafCert: X509Certificate; + leafCertPEM: string; + caKeyPair: { publicKey: JsonWebKey; privateKey: JsonWebKey }; + caKeyPairPEM: { publicKey: string; privateKey: string }; + caCert: X509Certificate; + caCertPEM: string; +}> { + let leafKeyPair: { publicKey: JsonWebKey; privateKey: JsonWebKey }; + let leafKeyPairPEM: { publicKey: string; privateKey: string }; + let caKeyPair: { publicKey: JsonWebKey; privateKey: JsonWebKey }; + let caKeyPairPEM: { publicKey: string; privateKey: string }; + switch (type) { + case 'RSA': + { + leafKeyPair = await generateKeyPairRSA(); + leafKeyPairPEM = await keyPairRSAToPEM(leafKeyPair); + caKeyPair = await generateKeyPairRSA(); + caKeyPairPEM = await keyPairRSAToPEM(caKeyPair); + } + break; + case 'ECDSA': + { + leafKeyPair = await generateKeyPairECDSA(); + leafKeyPairPEM = await keyPairECDSAToPEM(leafKeyPair); + caKeyPair = await generateKeyPairECDSA(); + caKeyPairPEM = await keyPairECDSAToPEM(caKeyPair); + } + break; + case 'Ed25519': + { + leafKeyPair = await generateKeyPairEd25519(); + leafKeyPairPEM = await keyPairEd25519ToPEM(leafKeyPair); + caKeyPair = await generateKeyPairEd25519(); + caKeyPairPEM = await keyPairEd25519ToPEM(caKeyPair); + } + break; + } + const caCert = await generateCertificate({ + certId: '0', + issuerPrivateKey: caKeyPair.privateKey, + subjectKeyPair: caKeyPair, + duration: 60 * 60 * 24 * 365 * 10, + }); + const leafCert = await generateCertificate({ + certId: '1', + issuerPrivateKey: caKeyPair.privateKey, + subjectKeyPair: leafKeyPair, + duration: 60 * 60 * 24 * 365 * 10, + }); + return { + leafKeyPair, + leafKeyPairPEM, + leafCert, + leafCertPEM: certToPEM(leafCert), + caKeyPair, + caKeyPairPEM, + caCert, + caCertPEM: certToPEM(caCert), + }; +} + +/** + * This will create a `reasonToCode` and `codeToReason` functions that will + * allow errors to "jump" the network boundary. It does this by mapping the + * errors to an incrementing code and returning them on the other end of the + * connection. + * + * Note: this should ONLY be used for testing as it requires the client and + * server to share the same instance of `reasonToCode` and `codeToReason`. + */ +function createReasonConverters() { + const reasonMap = new Map(); + let code = 0n; + + const reasonToCode: StreamReasonToCode = (_type, reason) => { + code++; + reasonMap.set(code, reason); + return code; + }; + + const codeToReason: StreamCodeToReason = (_type, code) => { + return reasonMap.get(code) ?? new Error('Reason not found'); + }; + + return { + reasonToCode, + codeToReason, + }; +} + export { - sleep -}; \ No newline at end of file + sleep, + yieldMicro, + randomBytes, + generateKeyPairRSA, + generateKeyPairECDSA, + generateKeyPairEd25519, + keyPairRSAToPEM, + keyPairECDSAToPEM, + keyPairEd25519ToPEM, + generateCertificate, + certToPEM, + generateKeyHMAC, + signHMAC, + verifyHMAC, + bufferWrap, + bufferArb, + handleStreamProm, + wsStats, + generateTLSConfig, + generateConfig, + createReasonConverters, +}; + +export type { Messages, StreamData, KeyTypes, TLSConfigs };