diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 84d689cea20e..1adcd5ceef00 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -287,6 +287,10 @@ { "filename": "sdk/loadtestservice/load-testing-rest/review/load-testing.api.md", "words": ["vusers"] + }, + { + "filename": "sdk/web-pubsub/web-pubsub-client/review/web-pubsub-client.api.md", + "words": ["protobuf"] } ] } diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 113b932457ab..dc7c85c93a4c 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -324,6 +324,7 @@ specifiers: '@rush-temp/testing-recorder-new': file:./projects/testing-recorder-new.tgz '@rush-temp/video-analyzer-edge': file:./projects/video-analyzer-edge.tgz '@rush-temp/web-pubsub': file:./projects/web-pubsub.tgz + '@rush-temp/web-pubsub-client': file:./projects/web-pubsub-client.tgz '@rush-temp/web-pubsub-express': file:./projects/web-pubsub-express.tgz dependencies: @@ -650,6 +651,7 @@ dependencies: '@rush-temp/testing-recorder-new': file:projects/testing-recorder-new.tgz '@rush-temp/video-analyzer-edge': file:projects/video-analyzer-edge.tgz '@rush-temp/web-pubsub': file:projects/web-pubsub.tgz + '@rush-temp/web-pubsub-client': file:projects/web-pubsub-client.tgz '@rush-temp/web-pubsub-express': file:projects/web-pubsub-express.tgz packages: @@ -1332,6 +1334,21 @@ packages: - supports-color dev: false + /@azure/web-pubsub/1.1.0: + resolution: {integrity: sha512-tGXFNw6UI6D2dl1EoPX/BAdkZWCnk3JI5QIyRp3wzmcWfZuXXAYXlKs3Hb1gWm3wbxZ1xf0Po5CQxqHhRW/zMw==} + engines: {node: '>=14.0.0'} + dependencies: + '@azure/core-auth': 1.4.0 + '@azure/core-client': 1.6.1 + '@azure/core-rest-pipeline': 1.10.0 + '@azure/core-tracing': 1.0.1 + '@azure/logger': 1.0.3 + jsonwebtoken: 8.5.1 + tslib: 2.4.1 + transitivePeerDependencies: + - supports-color + dev: false + /@babel/code-frame/7.12.11: resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==} dependencies: @@ -1674,6 +1691,14 @@ packages: '@jridgewell/sourcemap-codec': 1.4.14 dev: false + /@microsoft/api-extractor-model/7.13.9: + resolution: {integrity: sha512-t/XKTr8MlHRWgDr1fkyCzTQRR5XICf/WzIFs8yw1JLU8Olw99M3by4/dtpOZNskfqoW+J8NwOxovduU2csi4Ww==} + dependencies: + '@microsoft/tsdoc': 0.13.2 + '@microsoft/tsdoc-config': 0.15.2 + '@rushstack/node-core-library': 3.41.0 + dev: false + /@microsoft/api-extractor-model/7.25.2: resolution: {integrity: sha512-+h1uCrLQXFAKMUdghhdDcnniDB+6UA/lS9ArlB4QZQ34UbLuXNy2oQ6fafFK8cKXU4mUPTF/yGRjv7JKD5L7eg==} dependencies: @@ -1682,6 +1707,24 @@ packages: '@rushstack/node-core-library': 3.53.2 dev: false + /@microsoft/api-extractor/7.18.11: + resolution: {integrity: sha512-WfN5MZry4TrF60OOcGadFDsGECF9JNKNT+8P/8crYAumAYQRitI2cUiQRlCWrgmFgCWNezsNZeI/2BggdnUqcg==} + hasBin: true + dependencies: + '@microsoft/api-extractor-model': 7.13.9 + '@microsoft/tsdoc': 0.13.2 + '@microsoft/tsdoc-config': 0.15.2 + '@rushstack/node-core-library': 3.41.0 + '@rushstack/rig-package': 0.3.1 + '@rushstack/ts-command-line': 4.9.1 + colors: 1.2.5 + lodash: 4.17.21 + resolve: 1.17.0 + semver: 7.3.8 + source-map: 0.6.1 + typescript: 4.4.4 + dev: false + /@microsoft/api-extractor/7.33.6: resolution: {integrity: sha512-EYu1qWiMyvP/P+7na76PbE7+eOtvuYIvQa2DhZqkSQSLYP2sKLmZaSMK5Jvpgdr0fK/xLFujK5vLf3vpfcmC8g==} hasBin: true @@ -1700,6 +1743,15 @@ packages: typescript: 4.8.4 dev: false + /@microsoft/tsdoc-config/0.15.2: + resolution: {integrity: sha512-mK19b2wJHSdNf8znXSMYVShAHktVr/ib0Ck2FA3lsVBSEhSI/TfXT7DJQkAYgcztTuwazGcg58ZjYdk0hTCVrA==} + dependencies: + '@microsoft/tsdoc': 0.13.2 + ajv: 6.12.6 + jju: 1.4.0 + resolve: 1.19.0 + dev: false + /@microsoft/tsdoc-config/0.16.2: resolution: {integrity: sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==} dependencies: @@ -1709,6 +1761,10 @@ packages: resolve: 1.19.0 dev: false + /@microsoft/tsdoc/0.13.2: + resolution: {integrity: sha512-WrHvO8PDL8wd8T2+zBGKrMwVL5IyzR3ryWUsl0PXgEV0QHup4mTLi0QcATefGI6Gx9Anu7vthPyyyLpY0EpiQg==} + dev: false + /@microsoft/tsdoc/0.14.2: resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==} dev: false @@ -2164,6 +2220,20 @@ packages: rollup: 2.79.1 dev: false + /@rushstack/node-core-library/3.41.0: + resolution: {integrity: sha512-JxdmqR+SHU04jTDaZhltMZL3/XTz2ZZM47DTN+FSPUGUVp6WmxLlvJnT5FoHrOZWUjL/FoIlZUdUPTSXjTjIcg==} + dependencies: + '@types/node': 12.20.24 + colors: 1.2.5 + fs-extra: 7.0.1 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.17.0 + semver: 7.3.8 + timsort: 0.3.0 + z-schema: 3.18.4 + dev: false + /@rushstack/node-core-library/3.53.2: resolution: {integrity: sha512-FggLe5DQs0X9MNFeJN3/EXwb+8hyZUTEp2i+V1e8r4Va4JgkjBNY0BuEaQI+3DW6S4apV3UtXU3im17MSY00DA==} dependencies: @@ -2177,6 +2247,13 @@ packages: z-schema: 5.0.4 dev: false + /@rushstack/rig-package/0.3.1: + resolution: {integrity: sha512-DXQmrPWOCNoE2zPzHCShE1y47FlgbAg48wpaY058Qo/yKDzL0GlEGf5Ra2NIt22pMcp0R/HHh+kZGbqTnF4CrA==} + dependencies: + resolve: 1.17.0 + strip-json-comments: 3.1.1 + dev: false + /@rushstack/rig-package/0.3.17: resolution: {integrity: sha512-nxvAGeIMnHl1LlZSQmacgcRV4y1EYtgcDIrw6KkeVjudOMonlxO482PhDj3LVZEp6L7emSf6YSO2s5JkHlwfZA==} dependencies: @@ -2193,6 +2270,15 @@ packages: string-argv: 0.3.1 dev: false + /@rushstack/ts-command-line/4.9.1: + resolution: {integrity: sha512-zzoWB6OqVbMjnxlxbAUqbZqDWITUSHqwFCx7JbH5CVrjR9kcsB4NeWkN1I8GcR92beiOGvO3yPlB2NRo5Ugh+A==} + dependencies: + '@types/argparse': 1.0.38 + argparse: 1.0.10 + colors: 1.2.5 + string-argv: 0.3.1 + dev: false + /@sinonjs/commons/1.8.6: resolution: {integrity: sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==} dependencies: @@ -2961,7 +3047,7 @@ packages: dev: false /array-flatten/1.1.1: - resolution: {integrity: sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=} + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} dev: false /array-includes/3.1.6: @@ -3200,7 +3286,7 @@ packages: dev: false /buffer-equal-constant-time/1.0.1: - resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=} + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} dev: false /buffer-from/1.1.2: @@ -3357,7 +3443,7 @@ packages: dev: false /charenc/0.0.2: - resolution: {integrity: sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=} + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} dev: false /check-error/1.0.2: @@ -3493,6 +3579,7 @@ packages: /commander/2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + requiresBuild: true dev: false /commondir/1.0.1: @@ -3500,7 +3587,7 @@ packages: dev: false /concat-map/0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: false /concurrently/6.5.1: @@ -3561,7 +3648,7 @@ packages: dev: false /cookie-signature/1.0.6: - resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=} + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} dev: false /cookie/0.4.2: @@ -3674,7 +3761,7 @@ packages: dev: false /crypt/0.0.2: - resolution: {integrity: sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=} + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} dev: false /csv-parse/5.3.3: @@ -3850,6 +3937,10 @@ packages: engines: {node: '>=8'} dev: false + /devtools-protocol/0.0.1001819: + resolution: {integrity: sha512-G6OsIFnv/rDyxSqBa2lDLR6thp9oJioLsb2Gl+LbQlyoA9/OBAkrTU9jiCcQ8Pnh7z4d6slDiLaogR5hzgJLmQ==} + dev: false + /devtools-protocol/0.0.1056733: resolution: {integrity: sha512-CmTu6SQx2g3TbZzDCAV58+LTxVdKplS7xip0g5oDXpZ+isr0rv5dDP8ToyVRywzPHkCCPKgKgScEcwz4uPWDIA==} dev: false @@ -3928,7 +4019,7 @@ packages: dependencies: semver: 7.3.8 shelljs: 0.8.5 - typescript: 5.0.0-dev.20221206 + typescript: 5.0.0-dev.20221207 dev: false /downlevel-dts/0.8.0: @@ -3947,11 +4038,11 @@ packages: dev: false /edge-launcher/1.2.2: - resolution: {integrity: sha1-60Cq+9Bnpup27/+rBke81VCbN7I=} + resolution: {integrity: sha512-JcD5WBi3BHZXXVSSeEhl6sYO8g5cuynk/hifBzds2Bp4JdzCGLNMHgMCKu5DvrO1yatMgF0goFsxXRGus0yh1g==} dev: false /ee-first/1.1.1: - resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false /electron-to-chromium/1.4.284: @@ -4832,7 +4923,7 @@ packages: dev: false /fresh/0.5.2: - resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=} + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} dev: false @@ -4972,7 +5063,7 @@ packages: dev: false /github-from-package/0.0.0: - resolution: {integrity: sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4=} + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} dev: false /glob-parent/5.1.2: @@ -6388,7 +6479,7 @@ packages: dev: false /media-typer/0.3.0: - resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=} + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} dev: false @@ -6398,7 +6489,7 @@ packages: dev: false /merge-descriptors/1.0.1: - resolution: {integrity: sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=} + resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} dev: false /merge-source-map/1.1.0: @@ -6663,6 +6754,11 @@ packages: normalize-path: 2.1.1 dev: false + /mock-socket/9.1.5: + resolution: {integrity: sha512-3DeNIcsQixWHHKk6NdoBhWI4t1VMj5/HzfnI1rE/pLl5qKx7+gd4DNA07ehTaZ6MoUU053si6Hd+YtiM/tQZfg==} + engines: {node: '>= 8'} + dev: false + /module-details-from-path/1.0.3: resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} dev: false @@ -6815,7 +6911,7 @@ packages: dev: false /noms/0.0.0: - resolution: {integrity: sha1-2o69nzr51nYJGbJ9nNyAkqczKFk=} + resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==} dependencies: inherits: 2.0.4 readable-stream: 1.0.34 @@ -7448,6 +7544,31 @@ packages: - utf-8-validate dev: false + /puppeteer/14.4.1: + resolution: {integrity: sha512-+H0Gm84aXUvSLdSiDROtLlOofftClgw2TdceMvvCU9UvMryappoeS3+eOLfKvoy4sm8B8MWnYmPhWxVFudAOFQ==} + engines: {node: '>=14.1.0'} + deprecated: < 18.1.0 is no longer supported + requiresBuild: true + dependencies: + cross-fetch: 3.1.5 + debug: 4.3.4 + devtools-protocol: 0.0.1001819 + extract-zip: 2.0.1 + https-proxy-agent: 5.0.1 + pkg-dir: 4.2.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + rimraf: 3.0.2 + tar-fs: 2.1.1 + unbzip2-stream: 1.4.3 + ws: 8.7.0 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + /puppeteer/19.3.0: resolution: {integrity: sha512-WJbi/ULaeuFOz7cfMgJlJCBAZiyqIFeQ6os4h5ex3PVTt2qosXgwI9eruFZqFAwJRv8x5pOuMhWR0aSRgyDqEg==} engines: {node: '>=14.1.0'} @@ -8556,6 +8677,10 @@ packages: xtend: 4.0.2 dev: false + /timsort/0.3.0: + resolution: {integrity: sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==} + dev: false + /tmp/0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -8931,6 +9056,12 @@ packages: hasBin: true dev: false + /typescript/4.4.4: + resolution: {integrity: sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: false + /typescript/4.6.4: resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==} engines: {node: '>=4.2.0'} @@ -8949,8 +9080,8 @@ packages: hasBin: true dev: false - /typescript/5.0.0-dev.20221206: - resolution: {integrity: sha512-Yl7kEx5CxhDafU4SgjeLmlUZ7kbxKZBXlvnQQlfRLHtXSRyy4BsTx6h6loHDnCWkmRwpcyPJ88uquATOUGfLwQ==} + /typescript/5.0.0-dev.20221207: + resolution: {integrity: sha512-xPWWjASgLapa35nseHVRmQK6L0k3E1hb8OcN3I7XZHqYGUxk3n+RcncQSb4gGVromTEy7OHaoNSWLrkDO0+T1Q==} engines: {node: '>=4.2.0'} hasBin: true dev: false @@ -9074,7 +9205,7 @@ packages: dev: false /utils-merge/1.0.1: - resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=} + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} dev: false @@ -9114,6 +9245,11 @@ packages: engines: {node: '>= 0.10'} dev: false + /validator/8.2.0: + resolution: {integrity: sha512-Yw5wW34fSv5spzTXNkokD6S6/Oq92d8q/t14TqsS3fAiA1RYnxSFSIZ+CY3n6PGGRCq5HhJTSepQvFUS2QUDxA==} + engines: {node: '>= 0.10'} + dev: false + /vary/1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -9263,6 +9399,19 @@ packages: async-limiter: 1.0.1 dev: false + /ws/7.5.9: + resolution: {integrity: sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /ws/8.10.0: resolution: {integrity: sha512-+s49uSmZpvtAsd2h37vIPy1RBusaLawVe8of+GyEPsaJTCMpj/2v8NpeK1SHXjBlQ95lQTmQofOJnFiLoaN3yw==} engines: {node: '>=10.0.0'} @@ -9302,6 +9451,19 @@ packages: optional: true dev: false + /ws/8.7.0: + resolution: {integrity: sha512-c2gsP0PRwcLFzUiA8Mkr37/MI7ilIlHQxaEAtd0uNMbVMoy8puJyafRlm0bV9MbGSabUPeLrRRaqIBcFcA2Pqg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /xhr-mock/2.5.1: resolution: {integrity: sha512-UKOjItqjFgPUwQGPmRAzNBn8eTfIhcGjBVGvKYAWxUQPQsXNGD6KEckGTiHwyaAUp9C9igQlnN1Mp79KWCg7CQ==} dependencies: @@ -9482,6 +9644,17 @@ packages: engines: {node: '>=10'} dev: false + /z-schema/3.18.4: + resolution: {integrity: sha512-DUOKC/IhbkdLKKiV89gw9DUauTV8U/8yJl1sjf6MtDmzevLKOF2duNJ495S3MFVjqZarr+qNGCPbkg4mu4PpLw==} + hasBin: true + dependencies: + lodash.get: 4.4.2 + lodash.isequal: 4.5.0 + validator: 8.2.0 + optionalDependencies: + commander: 2.20.3 + dev: false + /z-schema/5.0.4: resolution: {integrity: sha512-gm/lx3hDzJNcLwseIeQVm1UcwhWIKpSB4NqH89pTBtFns4k/HDHudsICtvG05Bvw/Mv3jMyk700y5dadueLHdA==} engines: {node: '>=8.0.0'} @@ -15778,7 +15951,7 @@ packages: dev: false file:projects/communication-job-router.tgz: - resolution: {integrity: sha512-/i/QMGXP/g10uWLW/0Wrait10N30QJaEJKnNioHUwg6wLgaZKnKkGQCyZgbHKAB7o8rkQsNSLIYBz920/Mt1Fg==, tarball: file:projects/communication-job-router.tgz} + resolution: {integrity: sha512-aJ10TxMFU+MdVJyQmkeThKCZbnZ6QmU9tzOoBz7yXNgmNlTMDVjYE6dpwJnWgHpDjOLHlYfnO5sEepLhZ/8Z8A==, tarball: file:projects/communication-job-router.tgz} name: '@rush-temp/communication-job-router' version: 0.0.0 dependencies: @@ -15830,7 +16003,7 @@ packages: dev: false file:projects/communication-network-traversal.tgz: - resolution: {integrity: sha512-8BxjW4TN+CR+jUFtm7WWGCLTSlmwVL5tHETlolRYZ4pT7mbg5u5U4g90FNC2cuVcWD21efKwcxwqtG7YuPF3pw==, tarball: file:projects/communication-network-traversal.tgz} + resolution: {integrity: sha512-/gHUTUZtOKjvHerwEWLt+fwQYvXqdowYDQg+aJXp5/Q3TJrnDnnZD0LB+cy7rsFVnhbcIz4i+E78C6FoVb4zFw==, tarball: file:projects/communication-network-traversal.tgz} name: '@rush-temp/communication-network-traversal' version: 0.0.0 dependencies: @@ -16145,7 +16318,7 @@ packages: dev: false file:projects/core-amqp.tgz: - resolution: {integrity: sha512-FohZhL1nEjGi/9HxIaQKjgP07gL1O++dXUtBoYbeIq4DJhlY8wivalEbwErJch88M5l+vOY0e9jKfjSp8VXXHg==, tarball: file:projects/core-amqp.tgz} + resolution: {integrity: sha512-vJ7aD2zobl65Rwr8xx9e21lUr0OnvQSx5Wnhyy4YWdtZA77wD5hTLqB3nXbtQhni6siHOObS5UgZepNRivjq/g==, tarball: file:projects/core-amqp.tgz} name: '@rush-temp/core-amqp' version: 0.0.0 dependencies: @@ -16877,7 +17050,7 @@ packages: dev: false file:projects/dtdl-parser.tgz: - resolution: {integrity: sha512-tpIsaZWhGDJnpVqy/VriUYjEM5qzcjiQQGnK0KfuZy5x36FdS9w0p5SVKr3fZWnYpBYRNST/8H6m79K3A5xYJw==, tarball: file:projects/dtdl-parser.tgz} + resolution: {integrity: sha512-oadak9uqVnuit3rJujUthnEnz749jsjM7CwpLJrcr6L5LeP56GUTojveukyCl5ZqNyN1z4Fc6q2rHtnFBKnZyw==, tarball: file:projects/dtdl-parser.tgz} name: '@rush-temp/dtdl-parser' version: 0.0.0 dependencies: @@ -16966,7 +17139,7 @@ packages: dev: false file:projects/event-hubs.tgz: - resolution: {integrity: sha512-j/S7lD5JD3mwUE5WQWMzk7VDFqwkWiIBtJZIPAha28VrJoOM51Wi88bhftXNzvzA8pWvUBYVQN8KiFz9He+PMg==, tarball: file:projects/event-hubs.tgz} + resolution: {integrity: sha512-8Z/Lp4mVyIyb92vRhanHwLEvH1v94/qa08iKbBRpTwyyFau+hFu+/F70Gsigf1ZP8z2WjrT3X5MpTKGi9PkMiA==, tarball: file:projects/event-hubs.tgz} name: '@rush-temp/event-hubs' version: 0.0.0 dependencies: @@ -18227,7 +18400,7 @@ packages: dev: false file:projects/notification-hubs.tgz: - resolution: {integrity: sha512-f2Mz829O/deVSeFCWsgd/wsbBvyPQeEsbqiZ5Mpji8E1Mt73o5Hsgpio4UhGwYiamC+Tht7VgZ9Tz5PqqJ2jCw==, tarball: file:projects/notification-hubs.tgz} + resolution: {integrity: sha512-4SZI+GsuvYZ7ljHY8A8lqLnCgwOEDYS1mGM0M64Dr+dP7gXDSiOf2pYw06fKFrXZVg9h9GQwgBImh6CVjJAmdA==, tarball: file:projects/notification-hubs.tgz} name: '@rush-temp/notification-hubs' version: 0.0.0 dependencies: @@ -20082,6 +20255,63 @@ packages: - utf-8-validate dev: false + file:projects/web-pubsub-client.tgz: + resolution: {integrity: sha512-7gOoevPIYyhxg9TekmZn9VXM79KwcmMyYx5Kef48XgcfqcHK8C5fWZzv9smFZBukGtCqjHDWSlHhgSR5FC0wgg==, tarball: file:projects/web-pubsub-client.tgz} + name: '@rush-temp/web-pubsub-client' + version: 0.0.0 + dependencies: + '@azure-tools/test-recorder': 1.0.2 + '@azure/web-pubsub': 1.1.0 + '@microsoft/api-extractor': 7.18.11 + '@types/chai': 4.3.4 + '@types/express': 4.17.14 + '@types/express-serve-static-core': 4.17.31 + '@types/jsonwebtoken': 8.5.9 + '@types/mocha': 7.0.2 + '@types/node': 12.20.55 + '@types/sinon': 9.0.11 + '@types/ws': 7.4.7 + buffer: 6.0.3 + chai: 4.3.7 + cross-env: 7.0.3 + dotenv: 8.6.0 + eslint: 8.28.0 + esm: 3.2.25 + express: 4.18.2 + karma: 6.4.1 + karma-chrome-launcher: 3.1.1 + karma-coverage: 2.2.0 + karma-edge-launcher: 0.4.2_karma@6.4.1 + karma-env-preprocessor: 0.1.1 + karma-firefox-launcher: 1.3.0 + karma-ie-launcher: 1.0.0_karma@6.4.1 + karma-json-preprocessor: 0.3.3_karma@6.4.1 + karma-json-to-file-reporter: 1.0.1 + karma-junit-reporter: 2.0.1_karma@6.4.1 + karma-mocha: 2.0.1 + karma-mocha-reporter: 2.2.5_karma@6.4.1 + karma-sourcemap-loader: 0.3.8 + mocha: 7.2.0 + mocha-junit-reporter: 2.2.0_mocha@7.2.0 + mock-socket: 9.1.5 + nyc: 15.1.0 + prettier: 2.8.0 + puppeteer: 14.4.1 + rimraf: 3.0.2 + sinon: 9.2.4 + source-map-support: 0.5.21 + tslib: 2.4.1 + typescript: 4.6.4 + util: 0.12.5 + ws: 7.5.9 + transitivePeerDependencies: + - bufferutil + - debug + - encoding + - supports-color + - utf-8-validate + dev: false + file:projects/web-pubsub-express.tgz: resolution: {integrity: sha512-Sm7U17z478SPAkWu/W4F1/oPiCo+1GwYnA1Ct/QBh9tGHy6rKb1/rq+WAJl+qAufgxwQVlFWMWLtc8R6tuRWag==, tarball: file:projects/web-pubsub-express.tgz} name: '@rush-temp/web-pubsub-express' diff --git a/rush.json b/rush.json index 6830e914961a..30929a71d8eb 100644 --- a/rush.json +++ b/rush.json @@ -1938,6 +1938,11 @@ "packageName": "@azure/arm-loadtesting", "projectFolder": "sdk/loadtestservice/arm-loadtesting", "versionPolicyName": "management" + }, + { + "packageName": "@azure/web-pubsub-client", + "projectFolder": "sdk/web-pubsub/web-pubsub-client", + "versionPolicyName": "client" } ] } diff --git a/sdk/web-pubsub/ci.yml b/sdk/web-pubsub/ci.yml index 94cbc26faf6d..5be0d88a9eae 100644 --- a/sdk/web-pubsub/ci.yml +++ b/sdk/web-pubsub/ci.yml @@ -36,3 +36,5 @@ extends: safeName: webpubsub - name: azure-web-pubsub-express safeName: webpubsubexpress + - name: azure-web-pubsub-client + safeName: webpubsubclient diff --git a/sdk/web-pubsub/web-pubsub-client/.eslintrc.json b/sdk/web-pubsub/web-pubsub-client/.eslintrc.json new file mode 100644 index 000000000000..487d424bc78a --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "plugins": ["@azure/azure-sdk"], + "extends": ["plugin:@azure/azure-sdk/azure-sdk-base"], + "rules": { + "no-return-await": "off", + "no-empty": "off", + "no-constant-condition": "off" + } +} diff --git a/sdk/web-pubsub/web-pubsub-client/.vscode/settings.json b/sdk/web-pubsub/web-pubsub-client/.vscode/settings.json new file mode 100644 index 000000000000..ff30c44644d6 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.tabSize": 2 +} \ No newline at end of file diff --git a/sdk/web-pubsub/web-pubsub-client/CHANGELOG.md b/sdk/web-pubsub/web-pubsub-client/CHANGELOG.md new file mode 100644 index 000000000000..b9193032731f --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/CHANGELOG.md @@ -0,0 +1,5 @@ +# Release History + +## 1.0.0-beta.1 (Unreleased) + +Initial beta release diff --git a/sdk/web-pubsub/web-pubsub-client/README.md b/sdk/web-pubsub/web-pubsub-client/README.md new file mode 100644 index 000000000000..72f70f7c579a --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/README.md @@ -0,0 +1,113 @@ +# Azure Web PubSub Client + +[Azure Web PubSub service](https://aka.ms/awps/doc) is an Azure-managed service that helps developers easily build web applications with real-time features and publish-subscribe pattern. Any scenario that requires real-time publish-subscribe messaging between server and clients or among clients can use Azure Web PubSub service. Traditional real-time features that often require polling from server or submitting HTTP requests can also use Azure Web PubSub service. + +You can use this library in your client side to manage the WebSocket client connections, as shown in below diagram: + +![overflow](https://user-images.githubusercontent.com/668244/140014067-25a00959-04dc-47e8-ac25-6957bd0a71ce.png) + +Details about the terms used here are described in [Key concepts](#key-concepts) section. + +[API reference documentation](https://aka.ms/awps/sdk/js) | +[Product documentation](https://aka.ms/awps/doc) | +[Samples][samples_ref] + +## Getting started + +### Currently supported environments + +- [LTS versions of Node.js](https://nodejs.org/about/releases/) + +### Prerequisites + +- An [Azure subscription][azure_sub]. +- An existing Azure Web PubSub endpoint. + +### 1. Install the `@azure/web-pubsub-client` package + +```bash +npm install @azure/web-pubsub-client +``` + +### 2. Create a `WebPubSubClient` and copy `client-access-url` from Azure Portal + +```js +const { WebPubSubClient } = require("@azure/web-pubsub-client"); + +client = new WebPubSubClient("<>"); + +await client.start(); +``` + +## Key concepts + +### Connection + +A connection, also known as a client or a client connection, represents an individual WebSocket connection connected to the Web PubSub service. When successfully connected, a unique connection ID is assigned to this connection by the Web PubSub service. + +### Hub + +A hub is a logical concept for a set of client connections. Usually you use one hub for one purpose, for example, a chat hub, or a notification hub. When a client connection is created, it connects to a hub, and during its lifetime, it belongs to that hub. Different applications can share one Azure Web PubSub service by using different hub names. + +### Group + +A group is a subset of connections to the hub. You can add a client connection to a group, or remove the client connection from the group, anytime you want. For example, when a client joins a chat room, or when a client leaves the chat room, this chat room can be considered to be a group. A client can join multiple groups, and a group can contain multiple clients. + +### User + +Connections to Web PubSub can belong to one user. A user might have multiple connections, for example when a single user is connected across multiple devices or multiple browser tabs. + +### Client Events + +Events are created during the lifecycle of a client connection. For example, a simple WebSocket client connection creates a `connect` event when it tries to connect to the service, a `connected` event when it successfully connected to the service, a `message` event when it sends messages to the service and a `disconnected` event when it disconnects from the service. + +### Event Handler + +Event handler contains the logic to handle the client events. Event handler needs to be registered and configured in the service through the portal or Azure CLI beforehand. The place to host the event handler logic is generally considered as the server-side. + +## Examples + +### Start a client + +```js +const { WebPubSubClient } = require("@azure/web-pubsub-client"); + +client = new WebPubSubClient("<>"); + +await client.start(); +``` + +## Troubleshooting + +### Enable logs + +You can set the following environment variable to get the debug logs when using this library. + +- Getting debug logs from the SignalR client library + +```bash +export AZURE_LOG_LEVEL=verbose +``` + +For more detailed instructions on how to enable logs, you can look at the [@azure/logger package docs](https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/core/logger). + +### Live Trace + +Use **Live Trace** from the Web PubSub service portal to view the live traffic. + +## Next steps + +Please take a look at the +[samples][samples_ref] +directory for detailed examples on how to use this library. + +## Contributing + +If you'd like to contribute to this library, please read the [contributing guide](https://github.com/Azure/azure-sdk-for-js/blob/main/CONTRIBUTING.md) to learn more about how to build and test the code. + +## Related projects + +- [Microsoft Azure SDK for Javascript](https://github.com/Azure/azure-sdk-for-js) + +[azure_sub]: https://azure.microsoft.com/free/ +[samples_ref]: https://github.com/Azure/azure-webpubsub/tree/main/samples/javascript/ diff --git a/sdk/web-pubsub/web-pubsub-client/api-extractor.json b/sdk/web-pubsub/web-pubsub-client/api-extractor.json new file mode 100644 index 000000000000..4c2993a07507 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/api-extractor.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "types/src/index.d.ts", + "docModel": { + "enabled": true + }, + "apiReport": { + "enabled": true, + "reportFolder": "./review" + }, + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "", + "publicTrimmedFilePath": "./types/web-pubsub-client.d.ts" + }, + "messages": { + "tsdocMessageReporting": { + "default": { + "logLevel": "none" + } + }, + "extractorMessageReporting": { + "ae-missing-release-tag": { + "logLevel": "none" + }, + "ae-unresolved-link": { + "logLevel": "none" + } + } + } +} diff --git a/sdk/web-pubsub/web-pubsub-client/package.json b/sdk/web-pubsub/web-pubsub-client/package.json new file mode 100644 index 000000000000..ba04de9353b8 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/package.json @@ -0,0 +1,120 @@ +{ + "name": "@azure/web-pubsub-client", + "version": "1.0.0-beta.1", + "description": "Azure Web PubSub Client", + "sdk-type": "client", + "main": "dist/index.js", + "module": "dist-esm/src/index.js", + "browser": { + "buffer": "buffer" + }, + "types": "types/web-pubsub-client.d.ts", + "scripts": { + "audit": "node ../../../common/scripts/rush-audit.js && rimraf node_modules package-lock.json && npm i --package-lock-only 2>&1 && npm audit", + "build:browser": "tsc -p . && dev-tool run bundle", + "build:node": "tsc -p . && dev-tool run bundle", + "build:samples": "dev-tool samples publish -f", + "build:test": "tsc -p . && dev-tool run bundle", + "build": "npm run clean && tsc -p . && dev-tool run bundle && api-extractor run --local", + "check-format": "prettier --list-different --config ../../../.prettierrc.json --ignore-path ../../../.prettierignore \"src/**/*.ts\" \"test/**/*.ts\" \"samples-dev/**/*.ts\" \"*.{js,json}\"", + "format": "prettier --write --config ../../../.prettierrc.json --ignore-path ../../../.prettierignore \"src/**/*.ts\" \"test/**/*.ts\" \"samples-dev/**/*.ts\" \"*.{js,json}\"", + "clean": "rimraf dist dist-esm test-dist temp types *.tgz *.log", + "execute:samples": "dev-tool samples run samples-dev", + "extract-api": "tsc -p . && api-extractor run --local", + "integration-test:browser": "echo skipped", + "integration-test:node": "echo skipped", + "integration-test": "npm run integration-test:node && npm run integration-test:browser", + "lint:fix": "eslint package.json api-extractor.json README.md src test --ext .ts,.javascript,.js --fix --fix-type [problem,suggestion]", + "lint": "eslint package.json api-extractor.json README.md src test --ext .ts,.javascript,.js", + "pack": "npm pack 2>&1", + "test:browser": "npm run build:test && npm run unit-test:browser && npm run integration-test:browser", + "test:node": "npm run build:test && npm run unit-test:node && npm run integration-test:node", + "test": "npm run build:test && npm run unit-test && npm run integration-test", + "unit-test:browser": "echo skipped", + "unit-test:node": "mocha -r esm --require ts-node/register --reporter ../../../common/tools/mocha-multi-reporter.js --full-trace \"test/{,!(browser)/**/}*.spec.ts\"", + "unit-test": "npm run unit-test:node && npm run unit-test:browser" + }, + "files": [ + "dist/", + "dist-esm/", + "types/web-pubsub-client.d.ts", + "types/web-pubsub-client.d.ts.map", + "README.md", + "LICENSE" + ], + "repository": "github:Azure/azure-sdk-for-js", + "keywords": [ + "azure", + "cloud" + ], + "author": "Microsoft Corporation", + "license": "MIT", + "bugs": { + "url": "https://github.com/Azure/azure-sdk-for-js/issues" + }, + "engines": { + "node": ">=14.0.0" + }, + "homepage": "https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/web-pubsub/web-pubsub-client/README.md", + "sideEffects": false, + "dependencies": { + "tslib": "^2.2.0", + "@azure/logger": "^1.0.0", + "@azure/abort-controller": "^1.1.1", + "@azure/core-util": "^1.1.1", + "ws": "^7.4.5", + "buffer": "^6.0.0" + }, + "devDependencies": { + "@azure/dev-tool": "^1.0.0", + "@azure/eslint-plugin-azure-sdk": "^3.0.0", + "@azure-tools/test-recorder": "^1.0.0", + "@microsoft/api-extractor": "7.18.11", + "@types/chai": "^4.1.6", + "@types/express": "^4.16.0", + "@types/express-serve-static-core": "^4.17.19", + "@types/jsonwebtoken": "~8.5.0", + "@types/mocha": "^7.0.2", + "@types/node": "^12.0.0", + "@types/sinon": "^9.0.4", + "chai": "^4.2.0", + "cross-env": "^7.0.2", + "dotenv": "^8.2.0", + "eslint": "^8.0.0", + "esm": "^3.2.18", + "express": "^4.16.3", + "karma": "^6.2.0", + "karma-chrome-launcher": "^3.0.0", + "karma-coverage": "^2.0.0", + "karma-edge-launcher": "^0.4.2", + "karma-env-preprocessor": "^0.1.1", + "karma-firefox-launcher": "^1.1.0", + "karma-ie-launcher": "^1.0.0", + "karma-json-preprocessor": "^0.3.3", + "karma-json-to-file-reporter": "^1.0.1", + "karma-junit-reporter": "^2.0.1", + "karma-mocha": "^2.0.1", + "karma-mocha-reporter": "^2.2.5", + "karma-sourcemap-loader": "^0.3.8", + "mocha": "^7.1.1", + "mocha-junit-reporter": "^2.0.0", + "nyc": "^15.0.0", + "prettier": "^2.5.1", + "puppeteer": "^14.0.0", + "rimraf": "^3.0.0", + "sinon": "^9.0.2", + "source-map-support": "^0.5.9", + "typescript": "~4.6.0", + "@azure/web-pubsub": "1.1.0", + "@azure/test-utils": "1.0.0", + "mock-socket": "^9.1.5", + "util": "^0.12.1", + "@types/ws": "^7.4.5" + }, + "//sampleConfiguration": { + "productName": "Azure Web PubSub Client", + "productSlugs": [ + "azure" + ] + } +} diff --git a/sdk/web-pubsub/web-pubsub-client/review/web-pubsub-client.api.md b/sdk/web-pubsub/web-pubsub-client/review/web-pubsub-client.api.md new file mode 100644 index 000000000000..b4b789866ea3 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/review/web-pubsub-client.api.md @@ -0,0 +1,326 @@ +## API Report File for "@azure/web-pubsub-client" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +/// + +import { AbortSignalLike } from '@azure/abort-controller'; + +// @public +export interface AckMessage extends WebPubSubMessageBase { + ackId: number; + error?: AckMessageError; + readonly kind: "ack"; + success: boolean; +} + +// @public +export interface AckMessageError { + message: string; + name: string; +} + +// @public +export interface ConnectedMessage extends WebPubSubMessageBase { + connectionId: string; + readonly kind: "connected"; + reconnectionToken: string; + userId: string; +} + +// @public +export interface DisconnectedMessage extends WebPubSubMessageBase { + readonly kind: "disconnected"; + message: string; +} + +// @public +export type DownstreamMessageType = +/** +* Type for AckMessage +*/ +"ack" +/** +* Type for ConnectedMessage +*/ +| "connected" +/** +* Type for DisconnectedMessage +*/ +| "disconnected" +/** +* Type for GroupDataMessage +*/ +| "groupData" +/** +* Type for ServerDataMessage +*/ +| "serverData"; + +// @public +export interface GetClientAccessUrlOptions { + abortSignal?: AbortSignalLike; +} + +// @public +export interface GroupDataMessage extends WebPubSubMessageBase { + data: JSONTypes | ArrayBuffer; + dataType: WebPubSubDataType; + fromUserId: string; + group: string; + readonly kind: "groupData"; + sequenceId?: number; +} + +// @public +export interface JoinGroupMessage extends WebPubSubMessageBase { + ackId?: number; + group: string; + readonly kind: "joinGroup"; +} + +// @public +export interface JoinGroupOptions { + abortSignal?: AbortSignalLike; + ackId?: number; +} + +// @public +export type JSONTypes = string | number | boolean | object; + +// @public +export interface LeaveGroupMessage extends WebPubSubMessageBase { + ackId?: number; + group: string; + readonly kind: "leaveGroup"; +} + +// @public +export interface LeaveGroupOptions { + abortSignal?: AbortSignalLike; + ackId?: number; +} + +// @public +export interface OnConnectedArgs { + connectionId: string; + userId: string; +} + +// @public +export interface OnDisconnectedArgs { + connectionId?: string; + message?: DisconnectedMessage; +} + +// @public +export interface OnGroupDataMessageArgs { + message: GroupDataMessage; +} + +// @public +export interface OnRestoreGroupFailedArgs { + error: Error; + group: string; +} + +// @public +export interface OnServerDataMessageArgs { + message: ServerDataMessage; +} + +// @public +export interface OnStoppedArgs { +} + +// @public +export type RetryMode = "Exponential" | "Fixed"; + +// @public +export interface SendEventMessage extends WebPubSubMessageBase { + ackId?: number; + data: JSONTypes | ArrayBuffer; + dataType: WebPubSubDataType; + event: string; + readonly kind: "sendEvent"; +} + +// @public +export interface SendEventOptions { + abortSignal?: AbortSignalLike; + ackId?: number; + fireAndForget: boolean; +} + +// @public +export class SendMessageError extends Error { + constructor(message: string, options: SendMessageErrorOptions); + ackId?: number; + errorDetail?: AckMessageError; + name: string; +} + +// @public (undocumented) +export interface SendMessageErrorOptions { + ackId?: number; + errorDetail?: AckMessageError; +} + +// @public +export interface SendToGroupMessage extends WebPubSubMessageBase { + ackId?: number; + data: JSONTypes | ArrayBuffer; + dataType: WebPubSubDataType; + group: string; + readonly kind: "sendToGroup"; + noEcho: boolean; +} + +// @public +export interface SendToGroupOptions { + abortSignal?: AbortSignalLike; + ackId?: number; + fireAndForget: boolean; + noEcho: boolean; +} + +// @public +export interface SequenceAckMessage extends WebPubSubMessageBase { + readonly kind: "sequenceAck"; + sequenceId: number; +} + +// @public +export interface ServerDataMessage extends WebPubSubMessageBase { + data: JSONTypes | ArrayBuffer; + dataType: WebPubSubDataType; + readonly kind: "serverData"; + sequenceId?: number; +} + +// @public +export interface StartOptions { + abortSignal?: AbortSignalLike; +} + +// @public +export type UpstreamMessageType = +/** +* Type for JoinGroupMessage +*/ +"joinGroup" +/** +* Type for LeaveGroupMessage +*/ +| "leaveGroup" +/** +* Type for SendToGroupMessage +*/ +| "sendToGroup" +/** +* Type for SendEventMessage +*/ +| "sendEvent" +/** +* Type for SequenceAckMessage +*/ +| "sequenceAck"; + +// @public +export class WebPubSubClient { + constructor(clientAccessUri: string, options?: WebPubSubClientOptions); + constructor(credential: WebPubSubClientCredential, options?: WebPubSubClientOptions); + joinGroup(groupName: string, options?: JoinGroupOptions): Promise; + leaveGroup(groupName: string, options?: LeaveGroupOptions): Promise; + off(event: "connected", listener: (e: OnConnectedArgs) => void): void; + off(event: "disconnected", listener: (e: OnDisconnectedArgs) => void): void; + off(event: "stopped", listener: (e: OnStoppedArgs) => void): void; + off(event: "server-message", listener: (e: OnServerDataMessageArgs) => void): void; + off(event: "group-message", listener: (e: OnGroupDataMessageArgs) => void): void; + off(event: "rejoin-group-failed", listener: (e: OnRestoreGroupFailedArgs) => void): void; + on(event: "connected", listener: (e: OnConnectedArgs) => void): void; + on(event: "disconnected", listener: (e: OnDisconnectedArgs) => void): void; + on(event: "stopped", listener: (e: OnStoppedArgs) => void): void; + on(event: "server-message", listener: (e: OnServerDataMessageArgs) => void): void; + on(event: "group-message", listener: (e: OnGroupDataMessageArgs) => void): void; + on(event: "rejoin-group-failed", listener: (e: OnRestoreGroupFailedArgs) => void): void; + sendEvent(eventName: string, content: JSONTypes | ArrayBuffer, dataType: WebPubSubDataType, options?: SendEventOptions): Promise; + sendToGroup(groupName: string, content: JSONTypes | ArrayBuffer, dataType: WebPubSubDataType, options?: SendToGroupOptions): Promise; + start(options?: StartOptions): Promise; + stop(): void; +} + +// @public +export interface WebPubSubClientCredential { + getClientAccessUrl: string | ((options?: GetClientAccessUrlOptions) => Promise); +} + +// @public +export interface WebPubSubClientOptions { + autoReconnect?: boolean; + autoRestoreGroups?: boolean; + messageRetryOptions?: WebPubSubRetryOptions; + protocol?: WebPubSubClientProtocol; +} + +// @public +export interface WebPubSubClientProtocol { + readonly isReliableSubProtocol: boolean; + readonly name: string; + parseMessages(input: string | ArrayBuffer | Buffer): WebPubSubMessage | null; + writeMessage(message: WebPubSubMessage): string | ArrayBuffer; +} + +// @public +export type WebPubSubDataType = +/** +* Binary type +*/ +"binary" +/** +* Json type +*/ +| "json" +/** +* Text type +*/ +| "text" +/** +* Protobuf type +*/ +| "protobuf"; + +// @public +export const WebPubSubJsonProtocol: () => WebPubSubClientProtocol; + +// @public +export const WebPubSubJsonReliableProtocol: () => WebPubSubClientProtocol; + +// @public +export type WebPubSubMessage = GroupDataMessage | ServerDataMessage | JoinGroupMessage | LeaveGroupMessage | ConnectedMessage | DisconnectedMessage | SendToGroupMessage | SendEventMessage | SequenceAckMessage | AckMessage; + +// @public +export interface WebPubSubMessageBase { + // (undocumented) + kind: DownstreamMessageType | UpstreamMessageType; +} + +// @public +export interface WebPubSubResult { + ackId: number; + isDuplicated: boolean; +} + +// @public +export interface WebPubSubRetryOptions { + maxRetries?: number; + maxRetryDelayInMs?: number; + mode?: RetryMode; + retryDelayInMs?: number; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/sdk/web-pubsub/web-pubsub-client/sample.env b/sdk/web-pubsub/web-pubsub-client/sample.env new file mode 100644 index 000000000000..d918db9dc767 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/sample.env @@ -0,0 +1,2 @@ +# Retrieve this value from a Communication Services resource with a linked domain. +WPS_CONNECTION_STRING="Endpoint=https://.webpubsubdev.azure.com;AccessKey=;Version=1.0;" diff --git a/sdk/web-pubsub/web-pubsub-client/samples-dev/helloworld.ts b/sdk/web-pubsub/web-pubsub-client/samples-dev/helloworld.ts new file mode 100644 index 000000000000..356c4eb7a848 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/samples-dev/helloworld.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * @summary Basic usage of web-pubsub-client + */ + +import { + WebPubSubClient, + WebPubSubClientCredential, + SendToGroupOptions, + GetClientAccessUrlOptions, +} from "@azure/web-pubsub-client"; +import { WebPubSubServiceClient } from "@azure/web-pubsub"; +require("dotenv").config(); + +const serviceClient = new WebPubSubServiceClient(process.env.WPS_CONNECTION_STRING!, "chat"); + +let fetchClientAccessUrl = async (_: GetClientAccessUrlOptions) => { + return ( + await serviceClient.getClientAccessToken({ + roles: ["webpubsub.joinLeaveGroup", "webpubsub.sendToGroup"], + }) + ).url; +}; + +async function main() { + let client = new WebPubSubClient({ + getClientAccessUrl: fetchClientAccessUrl, + } as WebPubSubClientCredential); + + client.on("connected", (e) => { + console.log(`Connection ${e.connectionId} is connected.`); + }); + + client.on("disconnected", (e) => { + console.log(`Connection disconnected: ${e.message}`); + }); + + client.on("server-message", (e) => { + if (e.message.data instanceof ArrayBuffer) { + console.log(`Received message ${Buffer.from(e.message.data).toString("base64")}`); + } else { + console.log(`Received message ${e.message.data}`); + } + }); + + client.on("group-message", (e) => { + if (e.message.data instanceof ArrayBuffer) { + console.log( + `Received message from testGroup ${Buffer.from(e.message.data).toString("base64")}` + ); + } else { + console.log(`Received message from testGroup ${e.message.data}`); + } + }); + + await client.start(); + + await client.joinGroup("testGroup"); + await client.sendToGroup("testGroup", "hello world", "text", { + fireAndForget: true, + } as SendToGroupOptions); + await client.sendToGroup("testGroup", { a: 12, b: "hello" }, "json"); + await client.sendToGroup("testGroup", "hello json", "json"); + var buf = Buffer.from("aGVsbG9w", "base64"); + await client.sendToGroup( + "testGroup", + buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), + "binary" + ); + await delay(1000); + await client.stop(); +} + +main().catch((e) => { + console.error("Sample encountered an error", e); + process.exit(1); +}); + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/sdk/web-pubsub/web-pubsub-client/samples-dev/tsconfig.json b/sdk/web-pubsub/web-pubsub-client/samples-dev/tsconfig.json new file mode 100644 index 000000000000..76288239ab9f --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/samples-dev/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "CommonJS" + }, + "include": ["*.ts"], + "exclude": [] +} diff --git a/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/README.md b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/README.md new file mode 100644 index 000000000000..f3f00d07a783 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/README.md @@ -0,0 +1,54 @@ +--- +page_type: sample +languages: + - javascript +products: + - azure +urlFragment: web-pubsub-client-javascript-beta +--- + +# Azure Web PubSub Client client library samples for JavaScript (Beta) + +These sample programs show how to use the JavaScript client libraries for Azure Web PubSub Client in some common scenarios. + +| **File Name** | **Description** | +| --------------------------- | -------------------------------- | +| [helloworld.js][helloworld] | Basic usage of web-pubsub-client | + +## Prerequisites + +The sample programs are compatible with [LTS versions of Node.js](https://github.com/nodejs/release#release-schedule). + +You need [an Azure subscription][freesub] to run these sample programs. + +Samples retrieve credentials to access the service endpoint from environment variables. Alternatively, edit the source code to include the appropriate credentials. See each individual sample for details on which environment variables/credentials it requires to function. + +Adapting the samples to run in the browser may require some additional consideration. For details, please see the [package README][package]. + +## Setup + +To run the samples using the published version of the package: + +1. Install the dependencies using `npm`: + +```bash +npm install +``` + +2. Edit the file `sample.env`, adding the correct credentials to access the Azure service and run the samples. Then rename the file from `sample.env` to just `.env`. The sample programs will read this file automatically. + +3. Run whichever samples you like (note that some samples may require additional setup, see the table above): + +```bash +node helloworld.js +``` + +Alternatively, run a single sample with the correct environment variables set (setting up the `.env` file is not required if you do this), for example (cross-platform): + +```bash +npx cross-env WPS_CONNECTION_STRING="" node helloworld.js +``` + +## Next Steps + +Take a look at our [API Documentation][apiref] for more information about the APIs that are available in the clients. diff --git a/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/helloworld.js b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/helloworld.js new file mode 100644 index 000000000000..17aef10ab977 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/helloworld.js @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * @summary Basic usage of web-pubsub-client + */ + +const { WebPubSubClient } = require("@azure/web-pubsub-client"); +const { WebPubSubServiceClient } = require("@azure/web-pubsub"); + +const serviceClient = new WebPubSubServiceClient(process.env.WPS_CONNECTION_STRING, "chat"); + +let fetchClientAccessUrl = async (_) => { + return ( + await serviceClient.getClientAccessToken({ + roles: ["webpubsub.joinLeaveGroup", "webpubsub.sendToGroup"], + }) + ).url; +}; + +async function main() { + let client = new WebPubSubClient({ + getClientAccessUrl: fetchClientAccessUrl, + }); + + client.on("connected", (e) => { + console.log(`Connection ${e.connectionId} is connected.`); + }); + + client.on("disconnected", (e) => { + console.log(`Connection disconnected: ${e.message}`); + }); + + client.on("server-message", (e) => { + if (e.message.data instanceof ArrayBuffer) { + console.log(`Received message ${Buffer.from(e.message.data).toString("base64")}`); + } else { + console.log(`Received message ${e.message.data}`); + } + }); + + client.on("group-message", (e) => { + if (e.message.data instanceof ArrayBuffer) { + console.log( + `Received message from testGroup ${Buffer.from(e.message.data).toString("base64")}` + ); + } else { + console.log(`Received message from testGroup ${e.message.data}`); + } + }); + + await client.start(); + + await client.joinGroup("testGroup"); + await client.sendToGroup("testGroup", "hello world", "text", { + fireAndForget: true, + }); + await client.sendToGroup("testGroup", { a: 12, b: "hello" }, "json"); + await client.sendToGroup("testGroup", "hello json", "json"); + var buf = Buffer.from("aGVsbG9w", "base64"); + await client.sendToGroup( + "testGroup", + buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), + "binary" + ); + delay(1000); + await client.stop(); +} + +main().catch((e) => { + console.error("Sample encountered an error", e); + process.exit(1); +}); + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/package.json b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/package.json new file mode 100644 index 000000000000..3430c8ab0e96 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/package.json @@ -0,0 +1,29 @@ +{ + "name": "@azure-samples/web-pubsub-client-js-beta", + "private": true, + "version": "1.0.0", + "description": "Azure Web PubSub Client client library samples for JavaScript (Beta)", + "engines": { + "node": ">=14.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Azure/azure-sdk-for-js.git", + "directory": "sdk/web-pubsub/web-pubsub-client" + }, + "keywords": [ + "azure", + "cloud" + ], + "author": "Microsoft Corporation", + "license": "MIT", + "bugs": { + "url": "https://github.com/Azure/azure-sdk-for-js/issues" + }, + "homepage": "https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/web-pubsub/web-pubsub-client", + "dependencies": { + "@azure/web-pubsub-client": "next", + "dotenv": "latest", + "@azure/web-pubsub": "1.1.0" + } +} diff --git a/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/sample.env b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/sample.env new file mode 100644 index 000000000000..6b52838aea24 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/javascript/sample.env @@ -0,0 +1,2 @@ +# Retrieve this value from a Communication Services resource with a linked domain. +COMMUNICATION_CONNECTION_STRING="Endpoint=https://.webpubsubdev.azure.com;AccessKey=;Version=1.0;" diff --git a/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/README.md b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/README.md new file mode 100644 index 000000000000..633e27f7d3bb --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/README.md @@ -0,0 +1,66 @@ +--- +page_type: sample +languages: + - typescript +products: + - azure +urlFragment: web-pubsub-client-typescript-beta +--- + +# Azure Web PubSub Client client library samples for TypeScript (Beta) + +These sample programs show how to use the TypeScript client libraries for Azure Web PubSub Client in some common scenarios. + +| **File Name** | **Description** | +| --------------------------- | -------------------------------- | +| [helloworld.ts][helloworld] | Basic usage of web-pubsub-client | + +## Prerequisites + +The sample programs are compatible with [LTS versions of Node.js](https://github.com/nodejs/release#release-schedule). + +Before running the samples in Node, they must be compiled to JavaScript using the TypeScript compiler. For more information on TypeScript, see the [TypeScript documentation][typescript]. Install the TypeScript compiler using: + +```bash +npm install -g typescript +``` + +You need [an Azure subscription][freesub] to run these sample programs. + +Samples retrieve credentials to access the service endpoint from environment variables. Alternatively, edit the source code to include the appropriate credentials. See each individual sample for details on which environment variables/credentials it requires to function. + +Adapting the samples to run in the browser may require some additional consideration. For details, please see the [package README][package]. + +## Setup + +To run the samples using the published version of the package: + +1. Install the dependencies using `npm`: + +```bash +npm install +``` + +2. Compile the samples: + +```bash +npm run build +``` + +3. Edit the file `sample.env`, adding the correct credentials to access the Azure service and run the samples. Then rename the file from `sample.env` to just `.env`. The sample programs will read this file automatically. + +4. Run whichever samples you like (note that some samples may require additional setup, see the table above): + +```bash +node dist/helloworld.js +``` + +Alternatively, run a single sample with the correct environment variables set (setting up the `.env` file is not required if you do this), for example (cross-platform): + +```bash +npx cross-env WPS_CONNECTION_STRING="" node dist/helloworld.js +``` + +## Next Steps + +Take a look at our [API Documentation][apiref] for more information about the APIs that are available in the clients. diff --git a/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/package.json b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/package.json new file mode 100644 index 000000000000..4d2296e91786 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/package.json @@ -0,0 +1,38 @@ +{ + "name": "@azure-samples/web-pubsub-client-ts-beta", + "private": true, + "version": "1.0.0", + "description": "Azure Web PubSub Client client library samples for TypeScript (Beta)", + "engines": { + "node": ">=14.0.0" + }, + "scripts": { + "build": "tsc", + "prebuild": "rimraf dist/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Azure/azure-sdk-for-js.git", + "directory": "sdk/web-pubsub/web-pubsub-client" + }, + "keywords": [ + "azure", + "cloud" + ], + "author": "Microsoft Corporation", + "license": "MIT", + "bugs": { + "url": "https://github.com/Azure/azure-sdk-for-js/issues" + }, + "homepage": "https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/web-pubsub/web-pubsub-client", + "dependencies": { + "@azure/web-pubsub-client": "next", + "dotenv": "latest", + "@azure/web-pubsub": "1.1.0" + }, + "devDependencies": { + "@types/node": "^14.0.0", + "typescript": "~4.8.0", + "rimraf": "latest" + } +} diff --git a/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/sample.env b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/sample.env new file mode 100644 index 000000000000..6b52838aea24 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/sample.env @@ -0,0 +1,2 @@ +# Retrieve this value from a Communication Services resource with a linked domain. +COMMUNICATION_CONNECTION_STRING="Endpoint=https://.webpubsubdev.azure.com;AccessKey=;Version=1.0;" diff --git a/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/src/helloworld.ts b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/src/helloworld.ts new file mode 100644 index 000000000000..55b6c0b93601 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/src/helloworld.ts @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * @summary Basic usage of web-pubsub-client + */ + +import { + WebPubSubClient, + WebPubSubClientCredential, + SendToGroupOptions, + GetClientAccessUrlOptions, +} from "@azure/web-pubsub-client"; +import { WebPubSubServiceClient } from "@azure/web-pubsub"; + +const serviceClient = new WebPubSubServiceClient(process.env.WPS_CONNECTION_STRING!, "chat"); + +let fetchClientAccessUrl = async (_: GetClientAccessUrlOptions) => { + return ( + await serviceClient.getClientAccessToken({ + roles: ["webpubsub.joinLeaveGroup", "webpubsub.sendToGroup"], + }) + ).url; +}; + +async function main() { + let client = new WebPubSubClient({ + getClientAccessUrl: fetchClientAccessUrl, + } as WebPubSubClientCredential); + + client.on("connected", (e) => { + console.log(`Connection ${e.connectionId} is connected.`); + }); + + client.on("disconnected", (e) => { + console.log(`Connection disconnected: ${e.message}`); + }); + + client.on("server-message", (e) => { + if (e.message.data instanceof ArrayBuffer) { + console.log(`Received message ${Buffer.from(e.message.data).toString("base64")}`); + } else { + console.log(`Received message ${e.message.data}`); + } + }); + + client.on("group-message", (e) => { + if (e.message.data instanceof ArrayBuffer) { + console.log( + `Received message from testGroup ${Buffer.from(e.message.data).toString("base64")}` + ); + } else { + console.log(`Received message from testGroup ${e.message.data}`); + } + }); + + await client.start(); + + await client.joinGroup("testGroup"); + await client.sendToGroup("testGroup", "hello world", "text", { + fireAndForget: true, + } as SendToGroupOptions); + await client.sendToGroup("testGroup", { a: 12, b: "hello" }, "json"); + await client.sendToGroup("testGroup", "hello json", "json"); + var buf = Buffer.from("aGVsbG9w", "base64"); + await client.sendToGroup( + "testGroup", + buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), + "binary" + ); + delay(1000); + await client.stop(); +} + +main().catch((e) => { + console.error("Sample encountered an error", e); + process.exit(1); +}); + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/tsconfig.json b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/tsconfig.json new file mode 100644 index 000000000000..416c2dd82e00 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/samples/v1-beta/typescript/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2018", + "module": "commonjs", + "moduleResolution": "node", + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "alwaysStrict": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src/**.ts" + ] +} diff --git a/sdk/web-pubsub/web-pubsub-client/src/errors/index.ts b/sdk/web-pubsub/web-pubsub-client/src/errors/index.ts new file mode 100644 index 000000000000..2ed1f146d5a5 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/src/errors/index.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AckMessageError } from "../models/messages"; + +/** + * Error when sending message failed + */ +export class SendMessageError extends Error { + /** + * Error name + */ + public name: string; + /** + * The ack id of the message + */ + public ackId?: number; + /** + * The error details from the service + */ + public errorDetail?: AckMessageError; + /** + * Initialize a SendMessageError + * @param message - The error message + * @param ackMessage - The ack message + */ + constructor(message: string, options: SendMessageErrorOptions) { + super(message); + this.name = "SendMessageError"; + this.ackId = options.ackId; + this.errorDetail = options.errorDetail; + } +} + +export interface SendMessageErrorOptions { + /** + * The ack id of the message + */ + ackId?: number; + /** + * The error details from the service + */ + errorDetail?: AckMessageError; +} diff --git a/sdk/web-pubsub/web-pubsub-client/src/index.ts b/sdk/web-pubsub/web-pubsub-client/src/index.ts new file mode 100644 index 000000000000..e2e64e0420fe --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/src/index.ts @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +export * from "./webPubSubClient"; +export * from "./webPubSubClientCredential"; +export * from "./models"; +export * from "./protocols"; +export * from "./errors"; diff --git a/sdk/web-pubsub/web-pubsub-client/src/logger.ts b/sdk/web-pubsub/web-pubsub-client/src/logger.ts new file mode 100644 index 000000000000..efd659d313c7 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/src/logger.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createClientLogger } from "@azure/logger"; + +/** + * The \@azure\/logger configuration for this package. + */ +export const logger = createClientLogger("web-pubsub-client"); diff --git a/sdk/web-pubsub/web-pubsub-client/src/models/index.ts b/sdk/web-pubsub/web-pubsub-client/src/models/index.ts new file mode 100644 index 000000000000..e1d2334dba3a --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/src/models/index.ts @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AbortSignalLike } from "@azure/abort-controller"; +import { WebPubSubClientProtocol } from "../protocols"; +import { DisconnectedMessage, GroupDataMessage, ServerDataMessage } from "./messages"; + +/** + * The client options + */ +export interface WebPubSubClientOptions { + /** + * The subprotocol + */ + protocol?: WebPubSubClientProtocol; + /** + * Whether to auto reconnect after connection is dropped and not recoverable + */ + autoReconnect?: boolean; + /** + * Whether to enable restoring group after reconnecting + */ + autoRestoreGroups?: boolean; + /** + * The retry options for operations like joining group and sending messages + */ + messageRetryOptions?: WebPubSubRetryOptions; +} + +/** + * The retry options + */ +export interface WebPubSubRetryOptions { + /** + * Number of times the operation needs to be retried in case + * of retryable error. Default: 3. + */ + maxRetries?: number; + /** + * Amount of time to wait in milliseconds before making the + * next attempt. Default: `1000 milliseconds`. + * When `mode` option is set to `Exponential`, + * this is used to compute the exponentially increasing delays between retries. + */ + retryDelayInMs?: number; + /** + * Denotes the maximum delay between retries + * that the retry attempts will be capped at. Applicable only when performing exponential retry. + */ + maxRetryDelayInMs?: number; + /** + * Denotes which retry mode to apply. If undefined, defaults to `Fixed` + */ + mode?: RetryMode; +} + +/** + * Describes the Retry Mode type + */ +export type RetryMode = "Exponential" | "Fixed"; + +/** + * The start options + */ +export interface StartOptions { + /** + * The abort signal + */ + abortSignal?: AbortSignalLike; +} + +/** + * Join group operation options + */ +export interface JoinGroupOptions { + /** + * The optional ackId. If not specified, client will generate one. + */ + ackId?: number; + /** + * The abort signal + */ + abortSignal?: AbortSignalLike; +} + +/** + * Leave group operation options + */ +export interface LeaveGroupOptions { + /** + * The optional ackId. If not specified, client will generate one. + */ + ackId?: number; + /** + * The abort signal + */ + abortSignal?: AbortSignalLike; +} + +/** + * Send to group operation options + */ +export interface SendToGroupOptions { + /** + * Whether the message needs to echo to sender + */ + noEcho: boolean; + /** + * If true, the message won't contains ackId. No AckMessage will be returned from the service. + */ + fireAndForget: boolean; + /** + * The optional ackId. If not specified, client will generate one. + */ + ackId?: number; + /** + * The abort signal + */ + abortSignal?: AbortSignalLike; +} + +/** + * Send event operation options + */ +export interface SendEventOptions { + /** + * If true, the message won't contains ackId. No AckMessage will be returned from the service. + */ + fireAndForget: boolean; + /** + * The optional ackId. If not specified, client will generate one. + */ + ackId?: number; + /** + * The abort signal + */ + abortSignal?: AbortSignalLike; +} + +/** + * Parameter of OnConnected callback + */ +export interface OnConnectedArgs { + /** + * The connection id + */ + connectionId: string; + /** + * The user id of the client connection + */ + userId: string; +} + +/** + * Parameter of OnDisconnected callback + */ +export interface OnDisconnectedArgs { + /** + * The connection id + */ + connectionId?: string; + /** + * The disconnected message + */ + message?: DisconnectedMessage; +} + +/** + * Parameter of OnStopped callback + */ +export interface OnStoppedArgs {} + +/** + * Parameter of OnDataMessage callback + */ +export interface OnServerDataMessageArgs { + /** + * The data message + */ + message: ServerDataMessage; +} + +/** + * Parameter of OnGroupDataMessage callback + */ +export interface OnGroupDataMessageArgs { + /** + * The group data message + */ + message: GroupDataMessage; +} + +/** + * Parameter of RestoreGroupFailed callback + */ +export interface OnRestoreGroupFailedArgs { + /** + * The group name + */ + group: string; + /** + * The failure error + */ + error: Error; +} + +/** + * The ack result + */ +export interface WebPubSubResult { + /** + * The ack message from the service + */ + ackId: number; + /** + * Whether the message is duplicated. + */ + isDuplicated: boolean; +} + +/** + * The start options + */ +export interface GetClientAccessUrlOptions { + /** + * The abort signal + */ + abortSignal?: AbortSignalLike; +} + +export * from "./messages"; diff --git a/sdk/web-pubsub/web-pubsub-client/src/models/messages.ts b/sdk/web-pubsub/web-pubsub-client/src/models/messages.ts new file mode 100644 index 000000000000..2a60a024f117 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/src/models/messages.ts @@ -0,0 +1,327 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { JSONTypes } from "../webPubSubClient"; + +/** + * The web pubsub message + */ +export type WebPubSubMessage = + | GroupDataMessage + | ServerDataMessage + | JoinGroupMessage + | LeaveGroupMessage + | ConnectedMessage + | DisconnectedMessage + | SendToGroupMessage + | SendEventMessage + | SequenceAckMessage + | AckMessage; + +/** + * The common of web pubsub message + */ +export interface WebPubSubMessageBase { + kind: DownstreamMessageType | UpstreamMessageType; +} + +/** + * Types for downstream messages + */ +export type DownstreamMessageType = + /** + * Type for AckMessage + */ + | "ack" + /** + * Type for ConnectedMessage + */ + | "connected" + /** + * Type for DisconnectedMessage + */ + | "disconnected" + /** + * Type for GroupDataMessage + */ + | "groupData" + /** + * Type for ServerDataMessage + */ + | "serverData"; + +/** + * Types for upstream messages + */ +export type UpstreamMessageType = + /** + * Type for JoinGroupMessage + */ + | "joinGroup" + /** + * Type for LeaveGroupMessage + */ + | "leaveGroup" + /** + * Type for SendToGroupMessage + */ + | "sendToGroup" + /** + * Type for SendEventMessage + */ + | "sendEvent" + /** + * Type for SequenceAckMessage + */ + | "sequenceAck"; + +/** + * The ack message + */ +export interface AckMessage extends WebPubSubMessageBase { + /** + * Message type + */ + readonly kind: "ack"; + /** + * The correspending id + */ + ackId: number; + /** + * Is operation success or not + */ + success: boolean; + /** + * The error detail. Only available when success is false + */ + error?: AckMessageError; +} + +/** + * Error detail in AckMessage + */ +export interface AckMessageError { + /** + * Error name + */ + name: string; + /** + * Details error message + */ + message: string; +} + +/** + * Connected message + */ +export interface ConnectedMessage extends WebPubSubMessageBase { + /** + * Message type + */ + readonly kind: "connected"; + /** + * The connection id + */ + connectionId: string; + /** + * The user id of the client connection + */ + userId: string; + /** + * The reconnection token. Only available in reliable protocols. + */ + reconnectionToken: string; +} + +/** + * Disconnected message + */ +export interface DisconnectedMessage extends WebPubSubMessageBase { + /** + * Message type + */ + readonly kind: "disconnected"; + /** + * Reason of disconnection. + */ + message: string; +} + +/** + * Group data message + */ +export interface GroupDataMessage extends WebPubSubMessageBase { + /** + * Message type + */ + readonly kind: "groupData"; + /** + * The data type + */ + dataType: WebPubSubDataType; + /** + * The data + */ + data: JSONTypes | ArrayBuffer; + /** + * The sequence id of the data. Only available in reliable protocols + */ + sequenceId?: number; + /** + * The name of group that the message come from. + */ + group: string; + /** + * The user id of the sender + */ + fromUserId: string; +} + +/** + * Server data message + */ +export interface ServerDataMessage extends WebPubSubMessageBase { + /** + * Message type + */ + readonly kind: "serverData"; + /** + * The data type + */ + dataType: WebPubSubDataType; + /** + * The data + */ + data: JSONTypes | ArrayBuffer; + /** + * The sequence id of the data. Only available in reliable protocols + */ + sequenceId?: number; +} + +/** + * Join group message + */ +export interface JoinGroupMessage extends WebPubSubMessageBase { + /** + * Message type + */ + readonly kind: "joinGroup"; + /** + * The group to join + */ + group: string; + /** + * Optional ack id. If specified, an AckMessage with success or not will be returned with the same ackId + */ + ackId?: number; +} + +/** + * Leave group message + */ +export interface LeaveGroupMessage extends WebPubSubMessageBase { + /** + * Message type + */ + readonly kind: "leaveGroup"; + /** + * The group to leave + */ + group: string; + /** + * Optional ack id. If specified, an AckMessage with success or not will be returned with the same ackId + */ + ackId?: number; +} + +/** + * Send custom event message + */ +export interface SendEventMessage extends WebPubSubMessageBase { + /** + * Message type + */ + readonly kind: "sendEvent"; + /** + * Optional ack id. If specified, an AckMessage with success or not will be returned with the same ackId + */ + ackId?: number; + /** + * The data type + */ + dataType: WebPubSubDataType; + /** + * The data + */ + data: JSONTypes | ArrayBuffer; + /** + * The event name + */ + event: string; +} + +/** + * Send to group message + */ +export interface SendToGroupMessage extends WebPubSubMessageBase { + /** + * Message type + */ + readonly kind: "sendToGroup"; + /** + * The group to send + */ + group: string; + /** + * Optional ack id. If specified, an AckMessage with success or not will be returned with the same ackId + */ + ackId?: number; + /** + * The data type + */ + dataType: WebPubSubDataType; + /** + * The data + */ + data: JSONTypes | ArrayBuffer; + /** + * Whether the message needs to echo to sender + */ + noEcho: boolean; +} + +/** + * Sequence ack message + */ +export interface SequenceAckMessage extends WebPubSubMessageBase { + /** + * Message type + */ + readonly kind: "sequenceAck"; + /** + * The sequence id + */ + sequenceId: number; +} + +/** + * The data type + */ +export type WebPubSubDataType = + /** + * Binary type + */ + | "binary" + /** + * Json type + */ + | "json" + /** + * Text type + */ + | "text" + /** + * Protobuf type + */ + | "protobuf"; diff --git a/sdk/web-pubsub/web-pubsub-client/src/protocols/index.ts b/sdk/web-pubsub/web-pubsub-client/src/protocols/index.ts new file mode 100644 index 000000000000..2a26d1834500 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/src/protocols/index.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WebPubSubMessage } from "../models/messages"; +import { WebPubSubJsonProtocolImpl } from "./webPubSubJsonProtocol"; +import { WebPubSubJsonReliableProtocolImpl } from "./webPubSubJsonReliableProtocol"; + +/** + * The interface to be implemented for a web pubsub subprotocol + */ +export interface WebPubSubClientProtocol { + /** + * The name of subprotocol. Name will be used in websocket subprotocol + */ + readonly name: string; + + /** + * True if the protocol supports reliable features + */ + readonly isReliableSubProtocol: boolean; + + /** + * Creates WebPubSubMessage objects from the specified serialized representation. + * @param input - The serialized representation + */ + parseMessages(input: string | ArrayBuffer | Buffer): WebPubSubMessage | null; + + /** + * Write WebPubSubMessage to string or ArrayBuffer + * @param message - The message to be written + */ + writeMessage(message: WebPubSubMessage): string | ArrayBuffer; +} + +/** + * Return the "json.webpubsub.azure.v1" protocol + */ +export const WebPubSubJsonProtocol = (): WebPubSubClientProtocol => { + return new WebPubSubJsonProtocolImpl(); +}; + +/** + * Return the "json.reliable.webpubsub.azure.v1" protocol + */ +export const WebPubSubJsonReliableProtocol = (): WebPubSubClientProtocol => { + return new WebPubSubJsonReliableProtocolImpl(); +}; diff --git a/sdk/web-pubsub/web-pubsub-client/src/protocols/jsonProtocolBase.ts b/sdk/web-pubsub/web-pubsub-client/src/protocols/jsonProtocolBase.ts new file mode 100644 index 000000000000..f7737a60846e --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/src/protocols/jsonProtocolBase.ts @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + AckMessage, + ConnectedMessage, + DisconnectedMessage, + GroupDataMessage, + ServerDataMessage, + WebPubSubDataType, + WebPubSubMessage, +} from "../models/messages"; +import { JSONTypes } from "../webPubSubClient"; +import { Buffer } from "buffer"; + +export function parseMessages(input: string): WebPubSubMessage | null { + // The interface does allow "ArrayBuffer" to be passed in, but this implementation does not. So let's throw a useful error. + if (typeof input !== "string") { + throw new Error("Invalid input for JSON hub protocol. Expected a string."); + } + + if (!input) { + throw new Error("No input"); + } + + const parsedMessage = JSON.parse(input); + const typedMessage = parsedMessage as { type: string; from: string; event: string }; + let returnMessage: WebPubSubMessage; + + if (typedMessage.type === "system") { + if (typedMessage.event === "connected") { + returnMessage = { ...parsedMessage, kind: "connected" } as ConnectedMessage; + } else if (typedMessage.event === "disconnected") { + returnMessage = { ...parsedMessage, kind: "disconnected" } as DisconnectedMessage; + } else { + // Forward compatible + return null; + } + } else if (typedMessage.type === "message") { + if (typedMessage.from === "group") { + const data = parsePayload(parsedMessage.data, parsedMessage.dataType as WebPubSubDataType); + if (data === null) { + return null; + } + returnMessage = { ...parsedMessage, data: data, kind: "groupData" } as GroupDataMessage; + } else if (typedMessage.from === "server") { + const data = parsePayload(parsedMessage.data, parsedMessage.dataType as WebPubSubDataType); + if (data === null) { + return null; + } + returnMessage = { + ...parsedMessage, + data: data, + kind: "serverData", + } as ServerDataMessage; + } else { + // Forward compatible + return null; + } + } else if (typedMessage.type === "ack") { + returnMessage = { ...parsedMessage, kind: "ack" } as AckMessage; + } else { + // Forward compatible + return null; + } + return returnMessage; +} + +export function writeMessage(message: WebPubSubMessage): string { + let data: any; + switch (message.kind) { + case "joinGroup": { + data = { type: "joinGroup", group: message.group, ackId: message.ackId } as JoinGroupData; + break; + } + case "leaveGroup": { + data = { type: "leaveGroup", group: message.group, ackId: message.ackId } as LeaveGroupData; + break; + } + case "sendEvent": { + data = { + type: "event", + event: message.event, + ackId: message.ackId, + dataType: message.dataType, + data: getPayload(message.data, message.dataType), + } as SendEventData; + break; + } + case "sendToGroup": { + data = { + type: "sendToGroup", + group: message.group, + ackId: message.ackId, + dataType: message.dataType, + data: getPayload(message.data, message.dataType), + noEcho: message.noEcho, + } as SendToGroupData; + break; + } + case "sequenceAck": { + data = { type: "sequenceAck", sequenceId: message.sequenceId } as SequenceAckData; + break; + } + default: { + throw new Error(`Unsupported type: ${message.kind}`); + } + } + + return JSON.stringify(data); +} + +interface JoinGroupData { + readonly type: "joinGroup"; + group: string; + ackId?: number; +} + +interface LeaveGroupData { + readonly type: "leaveGroup"; + group: string; + ackId?: number; +} + +interface SendToGroupData { + readonly type: "sendToGroup"; + group: string; + ackId?: number; + dataType: WebPubSubDataType; + data: any; + noEcho: boolean; +} + +interface SendEventData { + readonly type: "event"; + ackId?: number; + dataType: WebPubSubDataType; + data: any; + event: string; +} + +interface SequenceAckData { + readonly type: "sequenceAck"; + sequenceId: number; +} + +function getPayload(data: JSONTypes | ArrayBuffer, dataType: WebPubSubDataType): any { + switch (dataType) { + case "text": { + if (typeof data !== "string") { + throw new TypeError("Message must be a string."); + } + return data; + } + case "json": { + return data; + } + case "binary": + case "protobuf": { + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("base64"); + } + throw new TypeError("Message must be a ArrayBuffer"); + } + } +} + +function parsePayload(data: any, dataType: string): JSONTypes | ArrayBuffer | null { + if (dataType === "text") { + if (typeof data !== "string") { + throw new TypeError("Message must be a string when dataType is text"); + } + return data as string; + } else if (dataType === "json") { + return data as JSONTypes; + } else if (dataType === "binary" || dataType === "protobuf") { + const buf = Buffer.from(data as string, "base64"); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; + } else { + // Forward compatible + return null; + } +} diff --git a/sdk/web-pubsub/web-pubsub-client/src/protocols/webPubSubJsonProtocol.ts b/sdk/web-pubsub/web-pubsub-client/src/protocols/webPubSubJsonProtocol.ts new file mode 100644 index 000000000000..25a287b093fb --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/src/protocols/webPubSubJsonProtocol.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WebPubSubClientProtocol } from "."; +import { WebPubSubMessage } from "../models/messages"; +import * as base from "./jsonProtocolBase"; + +/** + * The "json.webpubsub.azure.v1" protocol + */ +export class WebPubSubJsonProtocolImpl implements WebPubSubClientProtocol { + /** + * True if the protocol supports reliable features + */ + public readonly isReliableSubProtocol = false; + + /** + * The name of subprotocol. Name will be used in websocket subprotocol + */ + public readonly name = "json.webpubsub.azure.v1"; + + /** + * Creates WebPubSubMessage objects from the specified serialized representation. + * @param input - The serialized representation + */ + public parseMessages(input: string): WebPubSubMessage | null { + return base.parseMessages(input); + } + + /** + * Write WebPubSubMessage to string + * @param message - The message to be written + */ + public writeMessage(message: WebPubSubMessage): string { + return base.writeMessage(message); + } +} diff --git a/sdk/web-pubsub/web-pubsub-client/src/protocols/webPubSubJsonReliableProtocol.ts b/sdk/web-pubsub/web-pubsub-client/src/protocols/webPubSubJsonReliableProtocol.ts new file mode 100644 index 000000000000..180610b83f92 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/src/protocols/webPubSubJsonReliableProtocol.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WebPubSubClientProtocol } from "."; +import { WebPubSubMessage } from "../models/messages"; +import * as base from "./jsonProtocolBase"; + +/** + * The "json.reliable.webpubsub.azure.v1" protocol + */ +export class WebPubSubJsonReliableProtocolImpl implements WebPubSubClientProtocol { + /** + * True if the protocol supports reliable features + */ + public readonly isReliableSubProtocol = true; + + /** + * The name of subprotocol. Name will be used in websocket subprotocol + */ + public readonly name = "json.reliable.webpubsub.azure.v1"; + + /** + * Creates WebPubSubMessage objects from the specified serialized representation. + * @param input - The serialized representation + */ + public parseMessages(input: string): WebPubSubMessage | null { + return base.parseMessages(input); + } + + /** + * Write WebPubSubMessage to string + * @param message - The message to be written + */ + public writeMessage(message: WebPubSubMessage): string { + return base.writeMessage(message); + } +} diff --git a/sdk/web-pubsub/web-pubsub-client/src/webPubSubClient.ts b/sdk/web-pubsub/web-pubsub-client/src/webPubSubClient.ts new file mode 100644 index 000000000000..031db3cc91ac --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/src/webPubSubClient.ts @@ -0,0 +1,1113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AbortController, AbortSignalLike } from "@azure/abort-controller"; +import { delay } from "@azure/core-util"; +import EventEmitter from "events"; +import WebSocket, { CloseEvent, MessageEvent } from "ws"; +import { SendMessageError, SendMessageErrorOptions } from "./errors"; +import { logger } from "./logger"; +import { + WebPubSubResult, + JoinGroupOptions, + LeaveGroupOptions, + OnConnectedArgs, + OnDisconnectedArgs, + OnGroupDataMessageArgs, + OnServerDataMessageArgs, + OnStoppedArgs, + WebPubSubRetryOptions, + SendEventOptions, + SendToGroupOptions, + WebPubSubClientOptions, + OnRestoreGroupFailedArgs as OnRejoinGroupFailedArgs, + StartOptions, + GetClientAccessUrlOptions, +} from "./models"; +import { + ConnectedMessage, + DisconnectedMessage, + GroupDataMessage, + ServerDataMessage, + WebPubSubDataType, + WebPubSubMessage, + JoinGroupMessage, + LeaveGroupMessage, + SendToGroupMessage, + SendEventMessage, + AckMessage, + SequenceAckMessage, +} from "./models/messages"; +import { WebPubSubClientProtocol, WebPubSubJsonReliableProtocol } from "./protocols"; +import { WebPubSubClientCredential } from "./webPubSubClientCredential"; + +enum WebPubSubClientState { + Stopped = "Stopped", + Disconnected = "Disconnected", + Connecting = "Connecting", + Connected = "Connected", + Recovering = "Recovering", +} + +/** + * Types which can be serialized and sent as JSON. + */ +export type JSONTypes = string | number | boolean | object; + +/** + * The WebPubSub client + */ +export class WebPubSubClient { + private readonly _protocol: WebPubSubClientProtocol; + private readonly _credential: WebPubSubClientCredential; + private readonly _options: WebPubSubClientOptions; + private readonly _groupMap: Map; + private readonly _ackMap: Map; + private readonly _sequenceId: SequenceId; + private readonly _messageRetryPolicy: RetryPolicy; + private readonly _reconnectRetryPolicy: RetryPolicy; + + private readonly _emitter: EventEmitter = new EventEmitter(); + private _state: WebPubSubClientState; + private _isStopping: boolean = false; + private _ackId: number; + + // connection lifetime + private _socket?: WebSocket; + private _uri?: string; + private _lastCloseEvent?: CloseEvent; + private _lastDisconnectedMessage?: DisconnectedMessage; + private _connectionId?: string; + private _reconnectionToken?: string; + private _isInitialConnected = false; + private _sequenceAckTask?: AbortableTask; + + private nextAckId(): number { + this._ackId = this._ackId + 1; + return this._ackId; + } + + /** + * Create an instance of WebPubSubClient + * @param clientAccessUri - The uri to connect + * @param options - The client options + */ + constructor(clientAccessUri: string, options?: WebPubSubClientOptions); + /** + * Create an instance of WebPubSubClient + * @param credential - The credential to use when connecting + * @param options - The client options + */ + constructor(credential: WebPubSubClientCredential, options?: WebPubSubClientOptions); + constructor(credential: string | WebPubSubClientCredential, options?: WebPubSubClientOptions) { + if (typeof credential === "string") { + this._credential = { getClientAccessUrl: credential } as WebPubSubClientCredential; + } else { + this._credential = credential; + } + + if (options == null) { + options = {}; + } + this.buildDefaultOptions(options); + this._options = options; + + this._messageRetryPolicy = new RetryPolicy(this._options.messageRetryOptions!); + + // Expose to ClientOptions later + const connectRetryOptions: WebPubSubRetryOptions = { + maxRetries: Number.MAX_VALUE, + retryDelayInMs: 1000, + mode: "Fixed", + }; + this._reconnectRetryPolicy = new RetryPolicy(connectRetryOptions); + + this._protocol = this._options.protocol!; + this._groupMap = new Map(); + this._ackMap = new Map(); + this._sequenceId = new SequenceId(); + + this._state = WebPubSubClientState.Stopped; + this._ackId = 0; + } + + /** + * Start to start to the service. + * @param abortSignal - The abort signal + */ + public async start(options?: StartOptions): Promise { + if (this._isStopping) { + logger.error("Can't start a client during stopping"); + return; + } + + if (this._state !== WebPubSubClientState.Stopped) { + logger.warning("Client can be only started when it's Stopped"); + return; + } + + let abortSignal: AbortSignalLike | undefined; + if (options) { + abortSignal = options.abortSignal; + } + + try { + await this.startCore(abortSignal); + } catch (err) { + // this two sentense should be set together. Consider client.stop() is called during startCore() + this.ChangeState(WebPubSubClientState.Stopped); + this._isStopping = false; + throw err; + } + } + + private async startFromRestarting(abortSignal?: AbortSignalLike): Promise { + if (this._state !== WebPubSubClientState.Disconnected) { + logger.warning("Client can be only restarted when it's Disconnected"); + return; + } + + try { + await this.startCore(abortSignal); + } catch (err) { + this.ChangeState(WebPubSubClientState.Disconnected); + throw err; + } + } + + private async startCore(abortSignal?: AbortSignalLike): Promise { + this.ChangeState(WebPubSubClientState.Connecting); + + logger.info("Staring a new connection"); + // Reset before a pure new connection + this._sequenceId.reset(); + this._isInitialConnected = false; + this._lastCloseEvent = undefined; + this._lastDisconnectedMessage = undefined; + this._connectionId = undefined; + this._reconnectionToken = undefined; + this._uri = undefined; + + if (typeof this._credential.getClientAccessUrl === "string") { + this._uri = this._credential.getClientAccessUrl; + } else { + this._uri = await this._credential.getClientAccessUrl({ + abortSignal: abortSignal, + } as GetClientAccessUrlOptions); + } + + await this.connectCore(this._uri); + } + + /** + * Stop the client. + */ + public stop(): void { + if (this._state === WebPubSubClientState.Stopped || this._isStopping) { + return; + } + + this._isStopping = true; + if (this._socket) { + this._socket.close(); + } + } + + /** + * Add handler for connected event + * @param event - The event name + * @param listener - The handler + */ + public on(event: "connected", listener: (e: OnConnectedArgs) => void): void; + /** + * Add handler for disconnected event + * @param event - The event name + * @param listener - The handler + */ + public on(event: "disconnected", listener: (e: OnDisconnectedArgs) => void): void; + /** + * Add handler for stopped event + * @param event - The event name + * @param listener - The handler + */ + public on(event: "stopped", listener: (e: OnStoppedArgs) => void): void; + /** + * Add handler for server messages + * @param event - The event name + * @param listener - The handler + */ + public on(event: "server-message", listener: (e: OnServerDataMessageArgs) => void): void; + /** + * Add handler for group messags + * @param event - The event name + * @param listener - The handler + */ + public on(event: "group-message", listener: (e: OnGroupDataMessageArgs) => void): void; + /** + * Add handler for rejoining group failed + * @param event - The event name + * @param listener - The handler + */ + public on(event: "rejoin-group-failed", listener: (e: OnRejoinGroupFailedArgs) => void): void; + public on( + event: + | "connected" + | "disconnected" + | "stopped" + | "server-message" + | "group-message" + | "rejoin-group-failed", + listener: (e: any) => void + ): void { + this._emitter.on(event, listener); + } + + /** + * Remove handler for connected event + * @param event - The event name + * @param listener - The handler + */ + public off(event: "connected", listener: (e: OnConnectedArgs) => void): void; + /** + * Remove handler for disconnected event + * @param event - The event name + * @param listener - The handler + */ + public off(event: "disconnected", listener: (e: OnDisconnectedArgs) => void): void; + /** + * Remove handler for stopped event + * @param event - The event name + * @param listener - The handler + */ + public off(event: "stopped", listener: (e: OnStoppedArgs) => void): void; + /** + * Remove handler for server message + * @param event - The event name + * @param listener - The handler + */ + public off(event: "server-message", listener: (e: OnServerDataMessageArgs) => void): void; + /** + * Remove handler for group message + * @param event - The event name + * @param listener - The handler + */ + public off(event: "group-message", listener: (e: OnGroupDataMessageArgs) => void): void; + /** + * Remove handler for rejoining group failed + * @param event - The event name + * @param listener - The handler + */ + public off(event: "rejoin-group-failed", listener: (e: OnRejoinGroupFailedArgs) => void): void; + public off( + event: + | "connected" + | "disconnected" + | "stopped" + | "server-message" + | "group-message" + | "rejoin-group-failed", + listener: (e: any) => void + ): void { + this._emitter.removeListener(event, listener); + } + + private emitEvent(event: "connected", args: OnConnectedArgs): void; + private emitEvent(event: "disconnected", args: OnDisconnectedArgs): void; + private emitEvent(event: "stopped", args: OnStoppedArgs): void; + private emitEvent(event: "server-message", args: OnServerDataMessageArgs): void; + private emitEvent(event: "group-message", args: OnGroupDataMessageArgs): void; + private emitEvent(event: "rejoin-group-failed", args: OnRejoinGroupFailedArgs): void; + private emitEvent( + event: + | "connected" + | "disconnected" + | "stopped" + | "server-message" + | "group-message" + | "rejoin-group-failed", + args: any + ): void { + this._emitter.emit(event, args); + } + + /** + * Send custom event to server + * @param eventName - The event name + * @param content - The data content + * @param dataType - The data type + * @param ackId - The optional ackId. If not specified, client will generate one. + * @param options - The options + * @param abortSignal - The abort signal + */ + public async sendEvent( + eventName: string, + content: JSONTypes | ArrayBuffer, + dataType: WebPubSubDataType, + options?: SendEventOptions + ): Promise { + return await this.operationExecuteWithRetry( + () => this.sendEventAttempt(eventName, content, dataType, options), + options?.abortSignal + ); + } + + private async sendEventAttempt( + eventName: string, + content: JSONTypes | ArrayBuffer, + dataType: WebPubSubDataType, + options?: SendEventOptions + ): Promise { + const fireAndForget = options?.fireAndForget ?? false; + if (!fireAndForget) { + return await this.sendMessageWithAckId( + (id) => { + return { + kind: "sendEvent", + dataType: dataType, + data: content, + ackId: id, + event: eventName, + } as SendEventMessage; + }, + options?.ackId, + options?.abortSignal + ); + } + + const message = { + kind: "sendEvent", + dataType: dataType, + data: content, + event: eventName, + } as SendEventMessage; + + await this.sendMessage(message, options?.abortSignal); + return {} as WebPubSubResult; + } + + /** + * Join the client to group + * @param groupName - The group name + * @param options - The join group options + */ + public async joinGroup(groupName: string, options?: JoinGroupOptions): Promise { + return await this.operationExecuteWithRetry( + () => this.joinGroupAttempt(groupName, options), + options?.abortSignal + ); + } + + private async joinGroupAttempt( + groupName: string, + options?: JoinGroupOptions + ): Promise { + const group = this.getOrAddGroup(groupName); + const result = await this.joinGroupCore(groupName, options); + group.isJoined = true; + return result; + } + + private async joinGroupCore( + groupName: string, + options?: JoinGroupOptions + ): Promise { + return await this.sendMessageWithAckId( + (id) => { + return { + group: groupName, + ackId: id, + kind: "joinGroup", + } as JoinGroupMessage; + }, + options?.ackId, + options?.abortSignal + ); + } + + /** + * Leave the client from group + * @param groupName - The group name + * @param ackId - The optional ackId. If not specified, client will generate one. + * @param abortSignal - The abort signal + */ + public async leaveGroup( + groupName: string, + options?: LeaveGroupOptions + ): Promise { + return await this.operationExecuteWithRetry( + () => this.leaveGroupAttempt(groupName, options), + options?.abortSignal + ); + } + + private async leaveGroupAttempt( + groupName: string, + options?: LeaveGroupOptions + ): Promise { + const group = this.getOrAddGroup(groupName); + const result = await this.sendMessageWithAckId( + (id) => { + return { + group: groupName, + ackId: id, + kind: "leaveGroup", + } as LeaveGroupMessage; + }, + options?.ackId, + options?.abortSignal + ); + group.isJoined = false; + return result; + } + + /** + * Send message to group. + * @param groupName - The group name + * @param content - The data content + * @param dataType - The data type + * @param ackId - The optional ackId. If not specified, client will generate one. + * @param options - The options + * @param abortSignal - The abort signal + */ + public async sendToGroup( + groupName: string, + content: JSONTypes | ArrayBuffer, + dataType: WebPubSubDataType, + options?: SendToGroupOptions + ): Promise { + return await this.operationExecuteWithRetry( + () => this.sendToGroupAttempt(groupName, content, dataType, options), + options?.abortSignal + ); + } + + private async sendToGroupAttempt( + groupName: string, + content: JSONTypes | ArrayBuffer, + dataType: WebPubSubDataType, + options?: SendToGroupOptions + ): Promise { + const fireAndForget = options?.fireAndForget ?? false; + const noEcho = options?.noEcho ?? false; + if (!fireAndForget) { + return await this.sendMessageWithAckId( + (id) => { + return { + kind: "sendToGroup", + group: groupName, + dataType: dataType, + data: content, + ackId: id, + noEcho: noEcho, + } as SendToGroupMessage; + }, + options?.ackId, + options?.abortSignal + ); + } + + const message = { + kind: "sendToGroup", + group: groupName, + dataType: dataType, + data: content, + noEcho: noEcho, + } as SendToGroupMessage; + + await this.sendMessage(message, options?.abortSignal); + return {} as WebPubSubResult; + } + + private connectCore(uri: string): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(uri, this._protocol.name); + socket.binaryType = "arraybuffer"; + socket.onopen = (_) => { + // There's a case that client called stop() before this method. We need to check and close it if it's the case. + if (this._isStopping) { + try { + socket.close(); + } catch {} + + reject(); + } + this._socket = socket; + this._state = WebPubSubClientState.Connected; + if (this._protocol.isReliableSubProtocol) { + if (this._sequenceAckTask != null) { + this._sequenceAckTask.abort(); + } + this._sequenceAckTask = new AbortableTask(async () => { + const [isUpdated, seqId] = this._sequenceId.tryGetSequenceId(); + if (isUpdated) { + const message: SequenceAckMessage = { + kind: "sequenceAck", + sequenceId: seqId!, + }; + await this.sendMessage(message); + } + }, 1000); + } + + resolve(); + }; + + socket.onerror = (e) => { + if (this._sequenceAckTask != null) { + this._sequenceAckTask.abort(); + } + reject(e.error); + }; + + socket.onclose = (e) => { + if (this._state === WebPubSubClientState.Connected) { + if (this._sequenceAckTask != null) { + this._sequenceAckTask.abort(); + } + this._lastCloseEvent = e; + this.handleConnectionClose.call(this); + } else { + reject(e.reason); + } + }; + + socket.onmessage = (event: MessageEvent) => { + const handleAckMessage = (message: AckMessage): void => { + if (this._ackMap.delete(message.ackId)) { + const entity = this._ackMap.get(message.ackId)!; + if (message.success || (message.error && message.error.name === "Duplicate")) { + entity.resolve({ ackId: message.ackId, isDuplicated: true } as WebPubSubResult); + } else { + entity.reject( + new SendMessageError("Failed to send message.", { + ackId: message.ackId, + errorDetail: message.error, + } as SendMessageErrorOptions) + ); + } + } + }; + + const handleConnectedMessage = async (message: ConnectedMessage): Promise => { + this._connectionId = message.connectionId; + this._reconnectionToken = message.reconnectionToken; + + if (!this._isInitialConnected) { + this._isInitialConnected = true; + + const groupPromises: Promise[] = []; + this._groupMap.forEach((g) => { + if (g.isJoined) { + groupPromises.push( + (async () => { + try { + await this.joinGroupCore(g.name); + } catch (err) { + this.emitEvent("rejoin-group-failed", { + group: g.name, + error: err, + } as OnRejoinGroupFailedArgs); + } + })() + ); + } + }); + + try { + await Promise.all(groupPromises); + } catch {} + + this.emitEvent("connected", { + connectionId: message.connectionId, + userId: message.userId, + } as OnConnectedArgs); + } + }; + + const handleDisconnectedMessage = (message: DisconnectedMessage): void => { + this._lastDisconnectedMessage = message; + }; + + const handleGroupDataMessage = (message: GroupDataMessage): void => { + if (message.sequenceId != null) { + if (!this._sequenceId.tryUpdate(message.sequenceId)) { + // drop duplicated message + return; + } + } + + this.emitEvent("group-message", { + message: message, + } as OnGroupDataMessageArgs); + }; + + const handleServerDataMessage = (message: ServerDataMessage): void => { + if (message.sequenceId != null) { + if (!this._sequenceId.tryUpdate(message.sequenceId)) { + // drop duplicated message + return; + } + } + + this.emitEvent("server-message", { + message: message, + } as OnServerDataMessageArgs); + }; + + let message: WebPubSubMessage | null; + try { + const data = event.data; + let convertedData: Buffer | ArrayBuffer | string; + if (Array.isArray(data)) { + convertedData = Buffer.concat(data); + } else { + convertedData = data; + } + + message = this._protocol.parseMessages(convertedData); + if (message === null) { + // null means the message is not recognized. + return; + } + } catch (err) { + logger.warning("An error occurred while parsing the message from service", err); + throw err; + } + + try { + switch (message.kind) { + case "ack": { + handleAckMessage(message as AckMessage); + break; + } + case "connected": { + handleConnectedMessage(message as ConnectedMessage); + break; + } + case "disconnected": { + handleDisconnectedMessage(message as DisconnectedMessage); + break; + } + case "groupData": { + handleGroupDataMessage(message as GroupDataMessage); + break; + } + case "serverData": { + handleServerDataMessage(message as ServerDataMessage); + break; + } + } + } catch (err) { + logger.warning( + `An error occurred while handling the message with kind: ${message.kind} from service`, + err + ); + } + }; + }); + } + + private async handleConnectionCloseAndNoRecovery(): Promise { + this._state = WebPubSubClientState.Disconnected; + + this.emitEvent("disconnected", { + connectionId: this._connectionId, + message: this._lastDisconnectedMessage, + } as OnDisconnectedArgs); + + // Auto reconnect or stop + if (this._options.autoReconnect) { + await this.autoReconnect(); + } else { + await this.handleConnectionStopped(); + } + } + + private async autoReconnect(): Promise { + let isSuccess = false; + let attempt = 0; + try { + while (!this._isStopping) { + try { + await this.startFromRestarting(); + isSuccess = true; + break; + } catch (err) { + logger.warning("An attempt to reconnect connection failed", err); + + attempt++; + const delayInMs = this._reconnectRetryPolicy.nextRetryDelayInMs(attempt); + + if (delayInMs == null) { + break; + } + + try { + await delay(delayInMs); + } catch {} + } + } + } finally { + if (!isSuccess) { + this.handleConnectionStopped(); + } + } + } + + private handleConnectionStopped(): void { + this._isStopping = false; + this._state = WebPubSubClientState.Stopped; + this.emitEvent("stopped", {}); + } + + private async sendMessage( + message: WebPubSubMessage, + abortSignal?: AbortSignalLike + ): Promise { + const payload = this._protocol.writeMessage(message); + + if (!this._socket || this._socket.readyState !== WebSocket.OPEN) { + throw new Error("The connection is not connected."); + } + await sendAsync(this._socket, payload, abortSignal); + } + + private async sendMessageWithAckId( + messageProvider: (ackId: number) => WebPubSubMessage, + ackId?: number, + abortSignal?: AbortSignalLike + ): Promise { + if (ackId == null) { + ackId = this.nextAckId(); + } + + const message = messageProvider(ackId); + if (!this._ackMap.has(ackId)) { + this._ackMap.set(ackId, new AckEntity(ackId)); + } + const entity = this._ackMap.get(ackId)!; + + try { + await this.sendMessage(message, abortSignal); + } catch (error) { + this._ackMap.delete(ackId); + + let errorMessage: string = ""; + if (error instanceof Error) { + errorMessage = error.message; + } + throw new SendMessageError(errorMessage, { ackId: ackId }); + } + + return await entity.promise(); + } + + private async handleConnectionClose(): Promise { + // Clean ack cache + this._ackMap.forEach((value, key) => { + if (this._ackMap.delete(key)) { + value.reject( + new SendMessageError("Connection is disconnected before receive ack from the service", { + ackId: value.ackId, + } as SendMessageErrorOptions) + ); + } + }); + + if (this._isStopping) { + logger.warning("The client is stopping state. Stop recovery."); + this.handleConnectionCloseAndNoRecovery(); + return; + } + + if (this._lastCloseEvent && this._lastCloseEvent.code === 1008) { + logger.warning("The websocket close with status code 1008. Stop recovery."); + this.handleConnectionCloseAndNoRecovery(); + return; + } + + if (!this._protocol.isReliableSubProtocol) { + logger.warning("The protocol is not reliable, recovery is not applicable"); + this.handleConnectionCloseAndNoRecovery(); + return; + } + + // Build recovery uri + const recoveryUri = this.buildRecoveryUri(); + if (!recoveryUri) { + logger.warning("Connection id or reconnection token is not available"); + this.handleConnectionCloseAndNoRecovery(); + return; + } + + // Try recover connection + let recovered = false; + this._state = WebPubSubClientState.Recovering; + const abortSignal = AbortController.timeout(30 * 1000); + try { + while (!abortSignal.aborted || this._isStopping) { + try { + await this.connectCore.call(this, recoveryUri); + recovered = true; + return; + } catch { + await delay(1000); + } + } + } finally { + if (!recovered) { + logger.warning("Recovery attempts failed more then 30 seconds or the client is stopping"); + this.handleConnectionCloseAndNoRecovery(); + } + } + } + + private buildDefaultOptions(clientOptions: WebPubSubClientOptions): WebPubSubClientOptions { + if (clientOptions.autoReconnect == null) { + clientOptions.autoReconnect = true; + } + + if (clientOptions.autoRestoreGroups == null) { + clientOptions.autoRestoreGroups = true; + } + + if (clientOptions.protocol == null) { + clientOptions.protocol = WebPubSubJsonReliableProtocol(); + } + + this.buildMessageRetryOptions(clientOptions); + + return clientOptions; + } + + private buildMessageRetryOptions(clientOptions: WebPubSubClientOptions): void { + if (!clientOptions.messageRetryOptions) { + clientOptions.messageRetryOptions = {}; + } + + if ( + clientOptions.messageRetryOptions.maxRetries == null || + clientOptions.messageRetryOptions.maxRetries < 0 + ) { + clientOptions.messageRetryOptions.maxRetries = 3; + } + + if ( + clientOptions.messageRetryOptions.retryDelayInMs == null || + clientOptions.messageRetryOptions.retryDelayInMs < 0 + ) { + clientOptions.messageRetryOptions.retryDelayInMs = 1000; + } + + if ( + clientOptions.messageRetryOptions.maxRetryDelayInMs == null || + clientOptions.messageRetryOptions.maxRetryDelayInMs < 0 + ) { + clientOptions.messageRetryOptions.maxRetryDelayInMs = 30000; + } + + if (clientOptions.messageRetryOptions.mode == null) { + clientOptions.messageRetryOptions.mode = "Fixed"; + } + } + + private buildRecoveryUri(): string | null { + if (this._connectionId && this._reconnectionToken && this._uri) { + const url = new URL(this._uri); + url.searchParams.append("awps_connection_id", this._connectionId); + url.searchParams.append("awps_reconnection_token", this._reconnectionToken); + return url.toString(); + } + return null; + } + + private getOrAddGroup(name: string): WebPubSubGroup { + if (!this._groupMap.has(name)) { + this._groupMap.set(name, new WebPubSubGroup(name)); + } + return this._groupMap.get(name) as WebPubSubGroup; + } + + private ChangeState(newState: WebPubSubClientState): void { + logger.verbose( + `The client state transfer from ${this._state.toString()} to ${newState.toString()}` + ); + this._state = newState; + } + + private async operationExecuteWithRetry( + inner: () => Promise, + signal?: AbortSignalLike + ): Promise { + let retryAttempt = 0; + + while (true) { + try { + await inner.call(this); + } catch (err) { + retryAttempt++; + const delayInMs = this._messageRetryPolicy.nextRetryDelayInMs(retryAttempt); + if (delayInMs == null) { + throw err; + } + + try { + await delay(delayInMs); + } catch {} + + if (signal?.aborted) { + throw err; + } + } + } + } +} + +class RetryPolicy { + private _retryOptions: WebPubSubRetryOptions; + private _maxRetriesToGetMaxDelay: number; + + public constructor(retryOptions: WebPubSubRetryOptions) { + this._retryOptions = retryOptions; + this._maxRetriesToGetMaxDelay = Math.ceil( + Math.log2(this._retryOptions.maxRetryDelayInMs!) - + Math.log2(this._retryOptions.retryDelayInMs!) + + 1 + ); + } + + public nextRetryDelayInMs(retryAttempt: number): number | null { + if (retryAttempt > this._retryOptions.maxRetries!) { + return null; + } else { + if (this._retryOptions.mode! === "Fixed") { + return this._retryOptions.retryDelayInMs!; + } else { + return this.calculateExponentialDelay(retryAttempt); + } + } + } + + private calculateExponentialDelay(attempt: number): number { + if (attempt >= this._maxRetriesToGetMaxDelay) { + return this._retryOptions.maxRetryDelayInMs!; + } else { + return (1 << (attempt - 1)) * this._retryOptions.retryDelayInMs!; + } + } +} + +function sendAsync(socket: WebSocket, data: any, _?: AbortSignalLike): Promise { + return new Promise((resolve, reject) => { + socket.send(data, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +class WebPubSubGroup { + public readonly name: string; + public isJoined = false; + + constructor(name: string) { + this.name = name; + } +} + +class AckEntity { + private readonly _promise: Promise; + private _resolve?: (value: WebPubSubResult | PromiseLike) => void; + private _reject?: (reason?: any) => void; + + constructor(ackId: number) { + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + this.ackId = ackId; + } + + public ackId; + + promise(): Promise { + return this._promise; + } + + resolve(value: WebPubSubResult | PromiseLike): void { + this._resolve!(value); + } + + reject(reason?: any): void { + this._reject!(reason); + } +} + +class SequenceId { + private _sequenceId: number; + private _isUpdate: boolean; + + constructor() { + this._sequenceId = 0; + this._isUpdate = false; + } + + tryUpdate(sequenceId: number): boolean { + this._isUpdate = true; + if (sequenceId > this._sequenceId) { + this._sequenceId = sequenceId; + return true; + } + return false; + } + + tryGetSequenceId(): [boolean, number | null] { + if (this._isUpdate) { + this._isUpdate = false; + return [true, this._sequenceId]; + } + + return [false, null]; + } + + reset(): void { + this._sequenceId = 0; + this._isUpdate = false; + } +} + +class AbortableTask { + private readonly _func: (obj?: any) => Promise; + private readonly _abortController: AbortController; + private readonly _interval: number; + private readonly _obj?: any; + + constructor(func: (obj?: any) => Promise, interval: number, obj?: any) { + this._func = func; + this._abortController = new AbortController(); + this._interval = interval; + this._obj = obj; + this.start(); + } + + public abort(): void { + try { + this._abortController.abort(); + } catch {} + } + + private async start(): Promise { + const signal = this._abortController.signal; + while (!signal.aborted) { + try { + await this._func(this._obj); + } catch { + } finally { + await delay(this._interval); + } + } + } +} diff --git a/sdk/web-pubsub/web-pubsub-client/src/webPubSubClientCredential.ts b/sdk/web-pubsub/web-pubsub-client/src/webPubSubClientCredential.ts new file mode 100644 index 000000000000..e04e26265e0e --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/src/webPubSubClientCredential.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { GetClientAccessUrlOptions } from "./models"; + +/** + * The WebPubSubClient credential + */ +export interface WebPubSubClientCredential { + /** + * Gets an `getClientAccessUrl` which is used in connecting to the service + * @param abortSignal - An implementation of `AbortSignalLike` to cancel the operation. + */ + getClientAccessUrl: string | ((options?: GetClientAccessUrlOptions) => Promise); +} diff --git a/sdk/web-pubsub/web-pubsub-client/test/client.spec.ts b/sdk/web-pubsub/web-pubsub-client/test/client.spec.ts new file mode 100644 index 000000000000..eaca186b6ae2 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/test/client.spec.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "@azure/test-utils"; +import { WebPubSubClientOptions } from "../src/models"; +import { WebPubSubJsonProtocol } from "../src/protocols"; +import { WebPubSubClient } from "../src/webPubSubClient"; +import { WebPubSubClientCredential } from "../src/webPubSubClientCredential"; + +describe("WebPubSubClient", function () { + describe("Construct a new client and options", () => { + it("takes a client access url", () => { + assert.doesNotThrow(() => { + new WebPubSubClient("wss://service.com"); + }); + }); + + it("take client access url as func", () => { + assert.doesNotThrow(() => { + new WebPubSubClient({ + getClientAccessUrl: async (_) => "wss://service.com", + } as WebPubSubClientCredential); + }); + }); + + it("take options", () => { + assert.doesNotThrow(() => { + new WebPubSubClient( + { getClientAccessUrl: async (_) => "wss://service.com" } as WebPubSubClientCredential, + { protocol: WebPubSubJsonProtocol(), autoReconnect: false } as WebPubSubClientOptions + ); + }); + }); + + it("protocol is missing", () => { + assert.doesNotThrow(() => { + const client = new WebPubSubClient( + { getClientAccessUrl: async (_) => "wss://service.com" } as WebPubSubClientCredential, + { autoReconnect: false } as WebPubSubClientOptions + ); + const protocol = client["_protocol"]; + assert.equal("json.reliable.webpubsub.azure.v1", protocol.name); + const options = client["_options"]; + assert.isFalse(options.autoReconnect); + }); + }); + + it("reconnectionOptions is missing", () => { + assert.doesNotThrow(() => { + const client = new WebPubSubClient( + { getClientAccessUrl: async (_) => "wss://service.com" } as WebPubSubClientCredential, + {} as WebPubSubClientOptions + ); + const options = client["_options"]; + assert.isTrue(options.autoReconnect); + }); + }); + }); +}); diff --git a/sdk/web-pubsub/web-pubsub-client/test/jsonProtocol.spec.ts b/sdk/web-pubsub/web-pubsub-client/test/jsonProtocol.spec.ts new file mode 100644 index 000000000000..bf9e8887e241 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/test/jsonProtocol.spec.ts @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "@azure/test-utils"; +import { TextDecoder, TextEncoder } from "util"; +import { + AckMessage, + ConnectedMessage, + DisconnectedMessage, + GroupDataMessage, + JoinGroupMessage, + LeaveGroupMessage, + SendEventMessage, + SendToGroupMessage, + SequenceAckMessage, + ServerDataMessage, + WebPubSubMessage, +} from "../src/models"; +import { WebPubSubJsonReliableProtocol } from "../src/protocols"; + +describe("JsonProtocol", function () { + const protocol = WebPubSubJsonReliableProtocol(); + + describe("WriteMessage upstream messages", () => { + const tests = [ + { + testName: "JoinGroup1", + message: { kind: "joinGroup", group: "group" } as JoinGroupMessage, + payload: { type: "joinGroup", group: "group" }, + }, + { + testName: "JoinGroup2", + message: { kind: "joinGroup", group: "group", ackId: 44133 } as JoinGroupMessage, + payload: { type: "joinGroup", group: "group", ackId: 44133 }, + }, + { + testName: "leaveGroup1", + message: { kind: "leaveGroup", group: "group" } as LeaveGroupMessage, + payload: { type: "leaveGroup", group: "group" }, + }, + { + testName: "leaveGroup2", + message: { kind: "leaveGroup", group: "group", ackId: 12345 } as LeaveGroupMessage, + payload: { type: "leaveGroup", group: "group", ackId: 12345 }, + }, + { + testName: "sendToGroup1", + message: { + kind: "sendToGroup", + group: "group", + data: "xzy", + dataType: "text", + } as SendToGroupMessage, + payload: { type: "sendToGroup", group: "group", dataType: "text", data: "xzy" }, + }, + { + testName: "sendToGroup2", + message: { + kind: "sendToGroup", + group: "group", + data: { value: "xzy" }, + dataType: "json", + ackId: 12345, + noEcho: true, + } as SendToGroupMessage, + payload: { + type: "sendToGroup", + group: "group", + dataType: "json", + data: { value: "xzy" }, + ackId: 12345, + noEcho: true, + }, + }, + { + testName: "sendToGroup3", + message: { + kind: "sendToGroup", + group: "group", + data: new TextEncoder().encode("xyz").buffer, + dataType: "binary", + ackId: 12345, + noEcho: true, + } as SendToGroupMessage, + payload: { + type: "sendToGroup", + group: "group", + dataType: "binary", + data: "eHl6", + ackId: 12345, + noEcho: true, + }, + }, + { + testName: "sendToGroup4", + message: { + kind: "sendToGroup", + group: "group", + data: new TextEncoder().encode("xyz").buffer, + dataType: "protobuf", + ackId: 12345, + noEcho: true, + } as SendToGroupMessage, + payload: { + type: "sendToGroup", + group: "group", + dataType: "protobuf", + data: "eHl6", + ackId: 12345, + noEcho: true, + }, + }, + { + testName: "sendEvent1", + message: { + kind: "sendEvent", + event: "event", + data: "xzy", + dataType: "text", + } as SendEventMessage, + payload: { type: "event", event: "event", dataType: "text", data: "xzy" }, + }, + { + testName: "sendEvent2", + message: { + kind: "sendEvent", + event: "event", + data: { value: "xzy" }, + dataType: "json", + ackId: 12345, + } as SendEventMessage, + payload: { + type: "event", + event: "event", + dataType: "json", + data: { value: "xzy" }, + ackId: 12345, + }, + }, + { + testName: "sendEvent3", + message: { + kind: "sendEvent", + event: "event", + data: new TextEncoder().encode("xyz").buffer, + dataType: "binary", + ackId: 12345, + } as SendEventMessage, + payload: { type: "event", event: "event", dataType: "binary", data: "eHl6", ackId: 12345 }, + }, + { + testName: "sendEvent4", + message: { + kind: "sendEvent", + event: "event", + data: new TextEncoder().encode("xyz").buffer, + dataType: "protobuf", + ackId: 12345, + } as SendEventMessage, + payload: { + type: "event", + event: "event", + dataType: "protobuf", + data: "eHl6", + ackId: 12345, + }, + }, + { + testName: "seqAck1", + message: { kind: "sequenceAck", sequenceId: 123456 } as SequenceAckMessage, + payload: { type: "sequenceAck", sequenceId: 123456 }, + }, + ]; + + tests.forEach(({ testName, message, payload }) => { + it(`write message test ${testName}`, () => { + const writeMessage = protocol.writeMessage(message) as string; + assert.deepEqual(JSON.parse(writeMessage), payload); + }); + }); + }); + + describe("Parse downstream messages", () => { + const tests = [ + { + testName: "ack1", + message: { type: "ack", ackId: 123, success: true }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "ack"); + msg = msg as AckMessage; + assert.equal(msg.ackId, 123); + assert.equal(msg.success, true); + assert.isUndefined(msg.error); + }, + }, + { + testName: "ack2", + message: { + type: "ack", + ackId: 123, + success: false, + error: { name: "Forbidden", message: "message" }, + }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "ack"); + msg = msg as AckMessage; + assert.equal(msg.ackId, 123); + assert.equal(msg.success, false); + assert.equal(msg.error!.name, "Forbidden"); + assert.equal(msg.error!.message, "message"); + }, + }, + { + testName: "group1", + message: { + sequenceId: 12345, + type: "message", + from: "group", + group: "groupName", + dataType: "text", + data: "xyz", + fromUserId: "user", + }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "groupData"); + msg = msg as GroupDataMessage; + assert.equal(msg.group, "groupName"); + assert.equal(msg.sequenceId, 12345); + assert.equal(msg.dataType, "text"); + assert.equal(msg.data, "xyz"); + assert.equal(msg.fromUserId, "user"); + }, + }, + { + testName: "group2", + message: { + type: "message", + from: "group", + group: "groupName", + dataType: "json", + data: { value: "xyz" }, + fromUserId: "user", + }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "groupData"); + msg = msg as GroupDataMessage; + assert.equal(msg.group, "groupName"); + assert.isUndefined(msg.sequenceId); + assert.equal(msg.dataType, "json"); + assert.deepEqual(msg.data, { value: "xyz" }); + assert.equal(msg.fromUserId, "user"); + }, + }, + { + testName: "group3", + message: { + type: "message", + from: "group", + group: "groupName", + dataType: "binary", + data: "eHl6", + fromUserId: "user", + }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "groupData"); + msg = msg as GroupDataMessage; + assert.equal(msg.group, "groupName"); + assert.isUndefined(msg.sequenceId); + assert.equal(msg.dataType, "binary"); + assert.equal(new TextDecoder().decode(msg.data as ArrayBuffer), "xyz"); + assert.equal(msg.fromUserId, "user"); + }, + }, + { + testName: "group4", + message: { + type: "message", + from: "group", + group: "groupName", + dataType: "protobuf", + data: "eHl6", + fromUserId: "user", + }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "groupData"); + msg = msg as GroupDataMessage; + assert.equal(msg.group, "groupName"); + assert.isUndefined(msg.sequenceId); + assert.equal(msg.dataType, "protobuf"); + assert.equal(new TextDecoder().decode(msg.data as ArrayBuffer), "xyz"); + assert.equal(msg.fromUserId, "user"); + }, + }, + { + testName: "event1", + message: { + sequenceId: 12345, + type: "message", + from: "server", + dataType: "text", + data: "xyz", + }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "serverData"); + msg = msg as ServerDataMessage; + assert.equal(msg.sequenceId, 12345); + assert.equal(msg.dataType, "text"); + assert.equal(msg.data, "xyz"); + }, + }, + { + testName: "event2", + message: { type: "message", from: "server", dataType: "json", data: { value: "xyz" } }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "serverData"); + msg = msg as ServerDataMessage; + assert.isUndefined(msg.sequenceId); + assert.equal(msg.dataType, "json"); + assert.deepEqual(msg.data, { value: "xyz" }); + }, + }, + { + testName: "event3", + message: { type: "message", from: "server", dataType: "binary", data: "eHl6" }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "serverData"); + msg = msg as ServerDataMessage; + assert.isUndefined(msg.sequenceId); + assert.equal(msg.dataType, "binary"); + assert.equal(new TextDecoder().decode(msg.data as ArrayBuffer), "xyz"); + }, + }, + { + testName: "event4", + message: { type: "message", from: "server", dataType: "protobuf", data: "eHl6" }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "serverData"); + msg = msg as ServerDataMessage; + assert.isUndefined(msg.sequenceId); + assert.equal(msg.dataType, "protobuf"); + assert.equal(new TextDecoder().decode(msg.data as ArrayBuffer), "xyz"); + }, + }, + { + testName: "system1", + message: { type: "system", event: "connected", userId: "user", connectionId: "connection" }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "connected"); + msg = msg as ConnectedMessage; + assert.equal(msg.userId, "user"); + assert.equal(msg.connectionId, "connection"); + assert.isUndefined(msg.reconnectionToken); + }, + }, + { + testName: "system2", + message: { + type: "system", + event: "connected", + userId: "user", + connectionId: "connection", + reconnectionToken: "rec", + }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "connected"); + msg = msg as ConnectedMessage; + assert.equal(msg.userId, "user"); + assert.equal(msg.connectionId, "connection"); + assert.equal(msg.reconnectionToken, "rec"); + }, + }, + { + testName: "system3", + message: { type: "system", event: "disconnected", message: "msg" }, + assertFunc: (msg: WebPubSubMessage) => { + assert.equal(msg.kind, "disconnected"); + msg = msg as DisconnectedMessage; + assert.equal(msg.message, "msg"); + }, + }, + ]; + + tests.forEach(({ testName, message, assertFunc }) => { + it(`parse message test ${testName}`, () => { + const parsedMsg = protocol.parseMessages(JSON.stringify(message)); + assertFunc(parsedMsg!); + }); + }); + }); +}); diff --git a/sdk/web-pubsub/web-pubsub-client/tsconfig.json b/sdk/web-pubsub/web-pubsub-client/tsconfig.json new file mode 100644 index 000000000000..3b94d4cf7804 --- /dev/null +++ b/sdk/web-pubsub/web-pubsub-client/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.package", + "compilerOptions": { + "declarationDir": "./types", + "outDir": "./dist-esm", + "rootDir": ".", + "importHelpers": true, + "lib": ["DOM", "ES2017"], + "paths": { + "@azure/web-pubsub-client": ["./src/index"] + } + }, + "include": ["src/**/*.ts", "test/**/*.ts", "samples-dev/**/*.ts"] +}