diff --git a/STREAMING-EXTENSIONS.md b/STREAMING-EXTENSIONS.md new file mode 100644 index 000000000..a7b75d890 --- /dev/null +++ b/STREAMING-EXTENSIONS.md @@ -0,0 +1,99 @@ +# Direct Line Streaming Extensions + +This is CONTRIBUTING.md for Direct Line Streaming Extensions. + +## Run automated tests + +- Clone this repository branch +- `npm ci` + - Please ignore `node-gyp` errors, it is a warning instead +- `npm test` + +> You don't need to run `npm run build`. Jest will rebuild the source code on-the-fly for each test run. + +If you want to run tests in watch mode, run `npm test -- --watch`. + +## Build development bundle + +- Clone this repository +- `npm ci` +- `npm run build` + +After build succeeded, you can use the JavaScript bundle at `/dist/directline.js`. This is development build. It is not minified and contains instrumentation code for code coverage. + +To use the bundle: + +```js +const { DirectLine } = window.DirectLine; + +const directLine = new DirectLineStreaming({ + conversationId: '', + domain: 'https://.../.bot/v3/directline', + token: '', + webSocket: true +}); + +// Start the connection and console-logging every incoming activity +directLine.activity$.subscribe({ + next(activity) { console.log(activity); } +}); +``` + +## CI/CD pipeline + +### Build status + +For latest build status, navigate to https://travis-ci.org/microsoft/BotFramework-DirectLineJS/branches, and select `ckk/protocoljs` branch. + +### Test in Web Chat + +The last successful build can be tested with Web Chat and MockBot. + +- Navigate to https://compulim.github.io/webchat-loader/ +- Click `Dev` or select `` from the dropdown list +- Click `[Public] MockBot with Streaming Extensions` +- Click `Open Web Chat in a new window` + +Type `help` to MockBot for list of commands. + +### Build artifacts + +After successful build, artifacts are published to https://github.com/microsoft/BotFramework-DirectLineJS/releases/tag/dev-streamingextensions. + +For easier consumption, in the assets, [`directline.js`](https://github.com/microsoft/BotFramework-DirectLineJS/releases/download/dev-streamingextensions/directline.js) is the bundle from last successful build. You can use the HTML code below to use latest DirectLineJS with Web Chat 4.5.2: + +```html + + + + Web Chat with Streaming Extensions + + + + + + + + +``` + +### Source code + +Run `git checkout dev-streamingextensions` to checkout the source code of the last successful build. diff --git a/__tests__/happy.conversationUpdate.js b/__tests__/happy.conversationUpdate.js new file mode 100644 index 000000000..ceeea4e2d --- /dev/null +++ b/__tests__/happy.conversationUpdate.js @@ -0,0 +1,49 @@ +import 'dotenv/config'; + +import onErrorResumeNext from 'on-error-resume-next'; + +import { timeouts } from './constants.json'; +import * as createDirectLine from './setup/createDirectLine'; +import waitForBotToRespond from './setup/waitForBotToRespond'; +import waitForConnected from './setup/waitForConnected'; + +describe('Happy path', () => { + let unsubscribes; + + beforeEach(() => unsubscribes = []); + afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); + + describe('should receive the welcome message from bot', () => { + let directLine; + + describe('using REST', () => { + beforeEach(() => jest.setTimeout(timeouts.rest)); + + test('with token', async () => { + directLine = await createDirectLine.forREST({ token: true }); + }); + }); + + test('using Streaming Extensions', async () => { + jest.setTimeout(timeouts.webSocket); + directLine = await createDirectLine.forStreamingExtensions(); + }); + + describe('using Web Socket', () => { + beforeEach(() => jest.setTimeout(timeouts.webSocket)); + + test('with token', async () => { + directLine = await createDirectLine.forWebSocket({ token: true }); + }); + }); + + afterEach(async () => { + // If directLine object is undefined, that means the test is failing. + if (!directLine) { return; } + + unsubscribes.push(directLine.end.bind(directLine)); + + await waitForBotToRespond(directLine, ({ text }) => text === 'Welcome') + }); + }); +}); diff --git a/__tests__/happy.postActivity.js b/__tests__/happy.postActivity.js index 953fdcadb..6187da62a 100644 --- a/__tests__/happy.postActivity.js +++ b/__tests__/happy.postActivity.js @@ -29,10 +29,10 @@ describe('Happy path', () => { }); }); - // test('using Streaming Extensions', async () => { - // jest.setTimeout(timeouts.webSocket); - // directLine = await createDirectLine.forStreamingExtensions(); - // }); + test('using Streaming Extensions', async () => { + jest.setTimeout(timeouts.webSocket); + directLine = await createDirectLine.forStreamingExtensions(); + }); describe('using Web Socket', () => { beforeEach(() => jest.setTimeout(timeouts.webSocket)); diff --git a/__tests__/happy.receiveAttachmentStreams.js b/__tests__/happy.receiveAttachmentStreams.js new file mode 100644 index 000000000..0cde94354 --- /dev/null +++ b/__tests__/happy.receiveAttachmentStreams.js @@ -0,0 +1,61 @@ +import 'dotenv/config'; + +import onErrorResumeNext from 'on-error-resume-next'; + +import { timeouts } from './constants.json'; +import * as createDirectLine from './setup/createDirectLine'; +import fetchAsBase64 from './setup/fetchAsBase64'; +import postActivity from './setup/postActivity'; +import waitForBotToEcho from './setup/waitForBotToEcho'; +import waitForConnected from './setup/waitForConnected'; +import waitForBotToRespond from './setup/waitForBotToRespond.js'; + +describe('Happy path', () => { + let unsubscribes; + + beforeEach(() => unsubscribes = []); + afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); + + describe('receive attachments', () => { + let directLine; + + test('using Streaming Extensions', async () => { + jest.setTimeout(timeouts.webSocket); + directLine = await createDirectLine.forStreamingExtensions(); + }); + + afterEach(async () => { + // If directLine object is undefined, that means the test is failing. + if (!directLine) { return; } + + unsubscribes.push(directLine.end.bind(directLine)); + unsubscribes.push(await waitForConnected(directLine)); + + let url1 = 'http://myasebot.azurewebsites.net/177KB.jpg'; + let url2 = 'http://myasebot.azurewebsites.net/100KB.jpg'; + + const activityFromUser = { + text: 'attach ' + url1 + ' ' + url2, + type: 'message', + channelData: { + testType: "streaming" + } + }; + + await Promise.all([ + postActivity(directLine, activityFromUser), + waitForBotToRespond(directLine, async (activity) => { + if (!activity.channelData){ + return false; + } + let attachmentContents1 = await fetchAsBase64(url1); + let attachmentContents2 = await fetchAsBase64(url2); + const prefixLength = "data:text/plain;base64,".length; + return (activity.attachments.length == 2 && + attachmentContents1 == activity.attachments[0].contentUrl.substr(prefixLength) && + attachmentContents2 == activity.attachments[1].contentUrl.substr(prefixLength)); + }) + ]); + }); + }); +}); diff --git a/__tests__/happy.uploadAttachmentStreams.js b/__tests__/happy.uploadAttachmentStreams.js new file mode 100644 index 000000000..a8240d885 --- /dev/null +++ b/__tests__/happy.uploadAttachmentStreams.js @@ -0,0 +1,77 @@ +import 'dotenv/config'; + +import onErrorResumeNext from 'on-error-resume-next'; + +import { timeouts } from './constants.json'; +import * as createDirectLine from './setup/createDirectLine'; +import fetchAsBase64 from './setup/fetchAsBase64'; +import postActivity from './setup/postActivity'; +import waitForBotToEcho from './setup/waitForBotToEcho'; +import waitForConnected from './setup/waitForConnected'; + +describe('Happy path', () => { + let unsubscribes; + + beforeEach(() => unsubscribes = []); + afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); + + describe('upload 2 attachments with text messages', () => { + let directLine; + + test('using Streaming Extensions', async () => { + jest.setTimeout(timeouts.webSocket); + directLine = await createDirectLine.forStreamingExtensions(); + }); + + afterEach(async () => { + // If directLine object is undefined, that means the test is failing. + if (!directLine) { return; } + + unsubscribes.push(directLine.end.bind(directLine)); + unsubscribes.push(await waitForConnected(directLine)); + + const activityFromUser = { + // DirectLine.postActivityWithAttachments support "contentUrl" only but not "content" + attachments: [{ + contentType: 'image/jpg', + contentUrl: 'http://myasebot.azurewebsites.net/177KB.jpg' + }, { + contentType: 'image/jpg', + contentUrl: 'http://myasebot.azurewebsites.net/100KB.jpg' + }], + text: 'Hello, World!', + type: 'message', + channelData: { + testType: "streaming" + } + }; + + await Promise.all([ + postActivity(directLine, activityFromUser), + waitForBotToEcho(directLine, async ({ attachments, text }) => { + if (text === 'Hello, World!' && attachments) { + const [expectedContents, actualContents] = await Promise.all([ + Promise.all([ + fetchAsBase64(activityFromUser.attachments[0].contentUrl), + fetchAsBase64(activityFromUser.attachments[1].contentUrl) + ]), + ]); + + + let result = ( (expectedContents[0] === attachments[0].contentUrl && + expectedContents[1] === attachments[1].contentUrl) || + (expectedContents[1] === attachments[0].contentUrl && + expectedContents[0] === attachments[1].contentUrl) ); + + if (!result) { + console.warn(attachments[0].contentUrl); + console.warn(attachments[1].contentUrl); + } + + return result; + } + }) + ]); + }); + }); +}); diff --git a/__tests__/happy.uploadAttachments.js b/__tests__/happy.uploadAttachments.js index bd2a006cf..f61d44193 100644 --- a/__tests__/happy.uploadAttachments.js +++ b/__tests__/happy.uploadAttachments.js @@ -30,11 +30,6 @@ describe('Happy path', () => { }); }); - // test('using Streaming Extensions', async () => { - // jest.setTimeout(timeouts.webSocket); - // directLine = await createDirectLine.forStreamingExtensions(); - // }); - describe('using Web Socket', () => { beforeEach(() => jest.setTimeout(timeouts.webSocket)); @@ -57,13 +52,13 @@ describe('Happy path', () => { const activityFromUser = { // DirectLine.postActivityWithAttachments support "contentUrl" only but not "content" attachments: [{ - contentType: 'image/png', - contentUrl: 'https://webchat-waterbottle.azurewebsites.net/public/surfacelogo.png', - thumbnailUrl: 'data:image/png;base64,===surfacelogo.png' + contentType: 'image/jpg', + contentUrl: 'http://myasebot.azurewebsites.net/177KB.jpg', + thumbnailUrl: 'data:image/png;base64,===177KB.jpg' }, { contentType: 'image/png', - contentUrl: 'https://webchat-waterbottle.azurewebsites.net/public/xboxlogo.png', - thumbnailUrl: 'data:image/png;base64,===xboxlogo.png' + contentUrl: 'http://myasebot.azurewebsites.net/100KB.jpg', + thumbnailUrl: 'data:image/png;base64,===100KB.jpb' }], text: 'Hello, World!', type: 'message' diff --git a/__tests__/setup/createDirectLine.js b/__tests__/setup/createDirectLine.js index 8202eeef9..5812a5f8b 100644 --- a/__tests__/setup/createDirectLine.js +++ b/__tests__/setup/createDirectLine.js @@ -2,16 +2,17 @@ import fetch from 'node-fetch'; import { DirectLine } from '../../src/directLine'; import { userId as DEFAULT_USER_ID } from '../constants.json'; +import { DirectLineStreaming } from '../../src/directLineStreaming'; const { DIRECT_LINE_SECRET, - STREAMING_EXTENSIONS_DOMAIN = 'https://webchat-waterbottle.azurewebsites.net/.bot/v3/directline' + STREAMING_EXTENSIONS_DOMAIN = 'https://myasebot.azurewebsites.net/.bot/v3/directline' } = process.env; const DEFAULT_DOMAIN = 'https://directline.botframework.com/v3/directline'; async function fetchDirectLineToken() { - const res = await fetch('https://webchat-waterbottle.azurewebsites.net/token/directline'); + const res = await fetch('https://myasebot.azurewebsites.net/token/directline'); if (res.ok) { return await res.json(); @@ -21,7 +22,7 @@ async function fetchDirectLineToken() { } async function fetchDirectLineStreamingExtensionsToken() { - const res = await fetch(`${ STREAMING_EXTENSIONS_DOMAIN }/token/directline`); + const res = await fetch(`https://myasebot.azurewebsites.net/token/directlinease`); if (res.ok) { return await res.json(); @@ -79,10 +80,9 @@ export async function forStreamingExtensions(mergeOptions = {}) { : await fetchDirectLineStreamingExtensionsToken(); - return new DirectLine({ + return new DirectLineStreaming({ conversationId, domain: STREAMING_EXTENSIONS_DOMAIN, - streamingWebSocket: true, token, webSocket: true, ...mergeOptions diff --git a/package-lock.json b/package-lock.json index ae0714903..18361472d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2161,8 +2161,7 @@ "@types/node": { "version": "12.7.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.4.tgz", - "integrity": "sha512-W0+n1Y+gK/8G2P/piTkBBN38Qc5Q1ZSO6B5H3QmPCUewaiXOo2GCAWZ4ElZCcNhjJuBSUSLGFUJnmlCn5+nxOQ==", - "dev": true + "integrity": "sha512-W0+n1Y+gK/8G2P/piTkBBN38Qc5Q1ZSO6B5H3QmPCUewaiXOo2GCAWZ4ElZCcNhjJuBSUSLGFUJnmlCn5+nxOQ==" }, "@types/p-defer": { "version": "2.0.0", @@ -2179,6 +2178,14 @@ "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", "dev": true }, + "@types/ws": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.4.tgz", + "integrity": "sha512-PpPrX7SZW9re6+Ha8ojZG4Se8AZXgf0GK6zmfqEuCsY49LFDNXO3SByp44X3dFEqtB73lkCDAdUazhAjVPiNwg==", + "requires": { + "@types/node": "*" + } + }, "@types/yargs": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.2.tgz", @@ -2926,9 +2933,9 @@ "dev": true }, "bluebird": { - "version": "3.5.5", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.5.tgz", - "integrity": "sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true }, "bn.js": { @@ -2943,6 +2950,23 @@ "integrity": "sha512-IB1lgIywn37N9Aff8CciCblVpMUflgL42vyxPUH0IvaDdIi/QwBHKv1lq/HOkATHCfa7c4MbMYJ7Bo7hGuoI+w==", "dev": true }, + "botframework-streaming": { + "version": "4.8.0-preview-109324", + "resolved": "https://registry.npmjs.org/botframework-streaming/-/botframework-streaming-4.8.0-preview-109324.tgz", + "integrity": "sha512-ZhWRVlKQYj1I2bFTgrqVfJYC3+VnQxwW0ivrLwtWr0uGoTMg1TglXwsXrHgBKL9ATO6MCVFqIRjbmLywedFM3g==", + "requires": { + "@types/ws": "^6.0.3", + "uuid": "^3.3.2", + "ws": "^7.1.2" + }, + "dependencies": { + "ws": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.1.tgz", + "integrity": "sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A==" + } + } + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3103,9 +3127,9 @@ } }, "buffer": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", - "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", "dev": true, "requires": { "base64-js": "^1.0.2", @@ -3173,9 +3197,9 @@ }, "dependencies": { "glob": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", - "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", "dev": true, "requires": { "fs.realpath": "^1.0.0", @@ -3187,9 +3211,9 @@ } }, "graceful-fs": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.2.tgz", - "integrity": "sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", "dev": true }, "rimraf": { @@ -3293,9 +3317,9 @@ } }, "chownr": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz", - "integrity": "sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.3.tgz", + "integrity": "sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw==", "dev": true }, "chrome-trace-event": { @@ -3532,13 +3556,10 @@ } }, "console-browserify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", - "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", - "dev": true, - "requires": { - "date-now": "^0.1.4" - } + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", + "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", + "dev": true }, "constants-browserify": { "version": "1.0.0", @@ -3587,10 +3608,9 @@ "dev": true }, "core-js": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", - "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=", - "dev": true + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz", + "integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw==" }, "core-js-compat": { "version": "3.2.1", @@ -3653,6 +3673,22 @@ "sha.js": "^2.4.8" } }, + "cross-fetch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.4.tgz", + "integrity": "sha512-MSHgpjQqgbT/94D4CyADeNoYh52zMkCX4pcJvPP5WqPsLFMKjr2TCMg381ox5qI0ii2dPwaLx/00477knXqXVw==", + "requires": { + "node-fetch": "2.6.0", + "whatwg-fetch": "3.0.0" + }, + "dependencies": { + "whatwg-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", + "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==" + } + } + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -3781,12 +3817,6 @@ "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", "dev": true }, - "date-now": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", - "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", - "dev": true - }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3883,9 +3913,9 @@ "dev": true }, "des.js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", - "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", + "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", "dev": true, "requires": { "inherits": "^2.0.1", @@ -4018,9 +4048,9 @@ "dev": true }, "elliptic": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.1.tgz", - "integrity": "sha512-xvJINNLbTeWQjrl6X+7eQCrIy/YPv5XCpKW6kB5mKvtnGILoLDcySuwomfdzt0BMdLNVnuRNTuzKNHj0bva1Cg==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", + "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", "dev": true, "requires": { "bn.js": "^4.4.0", @@ -4219,9 +4249,9 @@ "dev": true }, "events": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.0.0.tgz", - "integrity": "sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.1.0.tgz", + "integrity": "sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg==", "dev": true }, "evp_bytestokey": { @@ -4470,6 +4500,14 @@ "promise": "^7.0.3", "ua-parser-js": "^0.7.9", "whatwg-fetch": "^0.9.0" + }, + "dependencies": { + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=", + "dev": true + } } }, "figgy-pudding": { @@ -7574,8 +7612,7 @@ "node-fetch": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", - "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", - "dev": true + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" }, "node-int64": { "version": "0.4.0", @@ -7937,9 +7974,9 @@ "dev": true }, "pako": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.10.tgz", - "integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "dev": true }, "parallel-transform": { @@ -7954,9 +7991,9 @@ } }, "parse-asn1": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.4.tgz", - "integrity": "sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.5.tgz", + "integrity": "sha512-jkMYn1dcJqF6d5CpU689bq7w/b5ALS9ROVSpQDPrZsqqesUJii9qutvoT5ltGedNXMO2e16YUWIghG9KxaViTQ==", "dev": true, "requires": { "asn1.js": "^4.0.0", @@ -8901,9 +8938,9 @@ } }, "serialize-javascript": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.9.1.tgz", - "integrity": "sha512-0Vb/54WJ6k5v8sSWN09S0ora+Hnr+cX40r9F170nT+mSkaxltoE/7R3OrIdBSUv1OoiobH1QoWQbCnAO+e8J1A==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz", + "integrity": "sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==", "dev": true }, "set-blocking": { @@ -9388,9 +9425,9 @@ } }, "stream-shift": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", - "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", "dev": true }, "stream-transform": { @@ -9479,9 +9516,9 @@ "dev": true }, "terser": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-4.3.0.tgz", - "integrity": "sha512-w5CzrvQOwYAH54aG22IrUJI4yX1w62XQmMdEOM6H4w0ii6rc3HJ89fmcOGN5mRwBWfUgaqO7RJTp4aoY/uE+qQ==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.6.3.tgz", + "integrity": "sha512-Lw+ieAXmY69d09IIc/yqeBqXpEQIpDGZqT34ui1QWXIUpR2RjbqEkT8X7Lgex19hslSqcWM5iMN2kM11eMsESQ==", "dev": true, "requires": { "commander": "^2.20.0", @@ -9490,9 +9527,9 @@ }, "dependencies": { "commander": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", - "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==", + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, "source-map": { @@ -9502,9 +9539,9 @@ "dev": true }, "source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.16.tgz", + "integrity": "sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==", "dev": true, "requires": { "buffer-from": "^1.0.0", @@ -9514,16 +9551,16 @@ } }, "terser-webpack-plugin": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz", - "integrity": "sha512-ZXmmfiwtCLfz8WKZyYUuuHf3dMYEjg8NrjHMb0JqHVHVOSkzp3cW2/XG1fP3tRhqEqSzMwzzRQGtAPbs4Cncxg==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz", + "integrity": "sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==", "dev": true, "requires": { "cacache": "^12.0.2", "find-cache-dir": "^2.1.0", "is-wsl": "^1.1.0", "schema-utils": "^1.0.0", - "serialize-javascript": "^1.7.0", + "serialize-javascript": "^2.1.2", "source-map": "^0.6.1", "terser": "^4.1.2", "webpack-sources": "^1.4.0", @@ -9904,6 +9941,11 @@ } } }, + "url-search-params-polyfill": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/url-search-params-polyfill/-/url-search-params-polyfill-8.0.0.tgz", + "integrity": "sha512-X4BTaEq1UMz9bTbMKQ6r+CippkKBsFWHiP9wycQc7aHH2Ml/Iieuo44+GJDb77pfP71bONYA/nUd4iokYAxVRQ==" + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -9938,8 +9980,7 @@ "uuid": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", - "dev": true + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" }, "v8-compile-cache": { "version": "2.0.3", @@ -9978,9 +10019,9 @@ } }, "vm-browserify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.0.tgz", - "integrity": "sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", "dev": true }, "w3c-hr-time": { @@ -10039,9 +10080,9 @@ "dev": true }, "webpack": { - "version": "4.39.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.39.3.tgz", - "integrity": "sha512-BXSI9M211JyCVc3JxHWDpze85CvjC842EvpRsVTc/d15YJGlox7GIDd38kJgWrb3ZluyvIjgenbLDMBQPDcxYQ==", + "version": "4.41.5", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.41.5.tgz", + "integrity": "sha512-wp0Co4vpyumnp3KlkmpM5LWuzvZYayDwM2n17EHFr4qxBBbRokC7DJawPJC7TfSFZ9HZ6GsdH40EBj4UV0nmpw==", "dev": true, "requires": { "@webassemblyjs/ast": "1.8.5", @@ -10064,29 +10105,35 @@ "node-libs-browser": "^2.2.1", "schema-utils": "^1.0.0", "tapable": "^1.1.3", - "terser-webpack-plugin": "^1.4.1", + "terser-webpack-plugin": "^1.4.3", "watchpack": "^1.6.0", "webpack-sources": "^1.4.1" }, "dependencies": { "acorn": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.3.0.tgz", - "integrity": "sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz", + "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==", "dev": true }, "ajv": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", - "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.11.0.tgz", + "integrity": "sha512-nCprB/0syFYy9fVYU1ox1l2KN8S9I+tziH8D4zdZuLT3N6RMlGSGt5FSTpAiHB/Whv8Qs1cWHma1aMKZyaHRKA==", "dev": true, "requires": { - "fast-deep-equal": "^2.0.1", + "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, + "fast-deep-equal": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", + "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==", + "dev": true + }, "neo-async": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz", diff --git a/package.json b/package.json index 6f1579d65..891821f68 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,11 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.6.0", - "rxjs": "^5.0.3" + "botframework-streaming": "4.8.0-preview-109324", + "core-js": "^3.6.4", + "cross-fetch": "^3.0.4", + "rxjs": "^5.0.3", + "url-search-params-polyfill": "^8.0.0" }, "devDependencies": { "@babel/cli": "^7.6.0", @@ -58,7 +62,7 @@ "rimraf": "^3.0.0", "simple-update-in": "^2.1.1", "typescript": "^3.6.2", - "webpack": "^4.39.3", + "webpack": "^4.41.0", "webpack-cli": "^3.3.8", "webpack-visualizer-plugin": "^0.1.11" } diff --git a/src/directLine.test.ts b/src/directLine.test.ts index a2c33a730..ecb504939 100644 --- a/src/directLine.test.ts +++ b/src/directLine.test.ts @@ -255,7 +255,7 @@ describe('MockSuite', () => { response: {id:'blah'}, status: 200 }; - expectedBotId = urlOrRequest.headers['x-ms-botid']; + expectedBotId = urlOrRequest.headers['x-ms-bot-id']; return response as AjaxResponse; } else if(urlOrRequest.url && urlOrRequest.url.indexOf('/conversations') > 0){ diff --git a/src/directLine.ts b/src/directLine.ts index 0b9e174a5..af09d973e 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -1,5 +1,7 @@ // In order to keep file size down, only import the parts of rxjs that we use +import 'core-js/features/promise'; +import 'url-search-params-polyfill'; import { AjaxResponse, AjaxCreationMethod, AjaxRequest, AjaxError } from 'rxjs/observable/dom/AjaxObservable'; import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { Observable } from 'rxjs/Observable'; @@ -31,6 +33,9 @@ import 'rxjs/add/observable/throw'; import dedupeFilenames from './dedupeFilenames'; import { objectExpression } from '@babel/types'; +import { DirectLineStreaming } from './directLineStreaming'; +export { DirectLineStreaming }; + const DIRECT_LINE_VERSION = 'DirectLine/3.0'; declare var process: { diff --git a/src/directLineStreaming.ts b/src/directLineStreaming.ts new file mode 100644 index 000000000..8b57bdc0a --- /dev/null +++ b/src/directLineStreaming.ts @@ -0,0 +1,338 @@ +// In order to keep file size down, only import the parts of rxjs that we use + +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; +import { Observable } from 'rxjs/Observable'; +import { Subscriber } from 'rxjs/Subscriber'; +import * as BFSE from 'botframework-streaming'; +import fetch from 'cross-fetch'; + +import { + Activity, + ConnectionStatus, + Conversation, + IBotConnection, + Media, + Message +} from './directLine'; + +const DIRECT_LINE_VERSION = 'DirectLine/3.0'; +const MAX_RETRY_COUNT = 3; +const refreshTokenLifetime = 30 * 60 * 1000; +//const refreshTokenLifetime = 5000; +const timeout = 20 * 1000; +const refreshTokenInterval = refreshTokenLifetime / 2; + +interface DirectLineStreamingOptions { + token: string, + conversationId?: string, + domain: string, + // Attached to all requests to identify requesting agent. + botAgent?: string +} + +class StreamHandler implements BFSE.RequestHandler { + private connectionStatus$; + private subscriber: Subscriber; + private shouldQueue: () => boolean; + private activityQueue: Array = []; + + constructor(s: Subscriber, c$: Observable, sq: () => boolean) { + this.subscriber = s; + this.connectionStatus$ = c$; + this.shouldQueue = sq; + } + + public setSubscriber(s: Subscriber) { + this.subscriber = s; + } + + async processRequest(request: BFSE.IReceiveRequest, logger?: any): Promise { + const streams = [...request.streams]; + const stream0 = streams.shift(); + const activitySetJson = await stream0.readAsString(); + const activitySet = JSON.parse(activitySetJson); + + if (activitySet.activities.length !== 1) { + // Only one activity is expected in a set in streaming + this.subscriber.error(new Error('there should be exactly one activity')); + return BFSE.StreamingResponse.create(500); + } + + const activity = activitySet.activities[0]; + + if (streams.length > 0) { + const attachments = [...activity.attachments]; + + let stream: BFSE.ContentStream; + while (stream = streams.shift()) { + const attachment = await stream.readAsString(); + const dataUri = "data:text/plain;base64," + attachment; + attachments.push({ contentType: stream.contentType, contentUrl: dataUri }); + } + + activity.attachments = attachments; + } + + if (this.shouldQueue()) { + this.activityQueue.push(activity); + } else { + this.subscriber.next(activity); + } + + return BFSE.StreamingResponse.create(200); + } + + public flush() { + this.connectionStatus$.subscribe(cs => { }) + this.activityQueue.forEach((a) => this.subscriber.next(a)); + this.activityQueue = []; + } +} + +export class DirectLineStreaming implements IBotConnection { + public connectionStatus$ = new BehaviorSubject(ConnectionStatus.Uninitialized); + public activity$: Observable; + + private activitySubscriber: Subscriber; + private theStreamHandler: StreamHandler; + + private domain: string; + + private conversationId: string; + private token: string; + private streamConnection: BFSE.WebSocketClient; + private queueActivities: boolean; + + private _botAgent = ''; + + constructor(options: DirectLineStreamingOptions) { + this.token = options.token; + + this.refreshToken(); + + this.domain = options.domain; + + if (options.conversationId) { + this.conversationId = options.conversationId; + } + + this._botAgent = this.getBotAgent(options.botAgent); + + this.queueActivities = true; + this.activity$ = Observable.create(async (subscriber: Subscriber) => { + this.activitySubscriber = subscriber; + this.theStreamHandler = new StreamHandler(subscriber, this.connectionStatus$, () => this.queueActivities); + this.connectWithRetryAsync(); + }).share(); + } + + public reconnect({ conversationId, token } : Conversation) { + this.conversationId = conversationId; + this.token = token; + this.connectAsync(); + } + + end() { + this.connectionStatus$.next(ConnectionStatus.Ended); + this.streamConnection.disconnect(); + } + + private commonHeaders() { + return { + "Authorization": `Bearer ${this.token}`, + "x-ms-bot-agent": this._botAgent + }; + } + + private getBotAgent(customAgent: string = ''): string { + let clientAgent = 'directlineStreaming' + + if (customAgent) { + clientAgent += `; ${customAgent}` + } + + return `${DIRECT_LINE_VERSION} (${clientAgent})`; + } + + private async refreshToken(firstCall = true, retryCount = 0) { + await this.waitUntilOnline(); + + let numberOfAttempts = 0; + while(numberOfAttempts < MAX_RETRY_COUNT) { + numberOfAttempts++; + await new Promise(r => setTimeout(r, refreshTokenInterval)); + try { + const res = await fetch(`${this.domain}/tokens/refresh`, {method: "POST", headers: this.commonHeaders()}); + if (res.ok) { + numberOfAttempts = 0; + const {token} = await res.json(); + this.token = token; + } else { + if (res.status === 403 || res.status === 403) { + console.error(`Fatal error while refreshing the token: ${res.status} ${res.statusText}`); + this.streamConnection.disconnect(); + } else { + console.warn(`Refresh attempt #${numberOfAttempts} failed: ${res.status} ${res.statusText}`); + } + } + } catch(e) { + console.warn(`Refresh attempt #${numberOfAttempts} threw an exception: ${e}`); + } + } + + console.error("Retries exhausted"); + this.streamConnection.disconnect(); + } + + postActivity(activity: Activity) { + if (activity.type === "message" && activity.attachments && activity.attachments.length > 0) { + return this.postMessageWithAttachments(activity); + } + + const resp$ = Observable.create(async subscriber => { + const request = BFSE.StreamingRequest.create('POST', '/v3/directline/conversations/' + this.conversationId + '/activities'); + request.setBody(JSON.stringify(activity)); + const resp = await this.streamConnection.send(request); + + try { + if (resp.statusCode !== 200) throw new Error("PostActivity returned " + resp.statusCode); + const numberOfStreams = resp.streams.length; + if (numberOfStreams !== 1) throw new Error("Expected one stream but got " + numberOfStreams) + const idString = await resp.streams[0].readAsString(); + const {Id : id} = JSON.parse(idString); + return subscriber.next(id); + } catch(e) { + // If there is a network issue then its handled by + // the disconnectionHandler. Everything else can + // be retried + console.warn(e); + this.streamConnection.disconnect(); + return subscriber.error(e); + } + }); + return resp$; + } + + private postMessageWithAttachments(message: Message) { + const { attachments, ...messageWithoutAttachments } = message; + + return Observable.create( subscriber => { + const httpContentList = []; + (async () => { + try { + const arrayBuffers = await Promise.all(attachments.map(async attachment => { + const media = attachment as Media; + const res = await fetch(media.contentUrl); + if (res.ok) { + return { arrayBuffer: await res.arrayBuffer(), media }; + } else { + throw new Error('...'); + } + })); + + arrayBuffers.forEach(({ arrayBuffer, media }) => { + const buffer = new Buffer(arrayBuffer); + console.log(buffer); + const stream = new BFSE.SubscribableStream(); + stream.write(buffer); + const httpContent = new BFSE.HttpContent({ type: media.contentType, contentLength: buffer.length }, stream); + httpContentList.push(httpContent); + }); + + const url = `/v3/directline/conversations/${this.conversationId}/users/${messageWithoutAttachments.from.id}/upload`; + const request = BFSE.StreamingRequest.create('PUT', url); + const activityStream = new BFSE.SubscribableStream(); + activityStream.write(JSON.stringify(messageWithoutAttachments), 'utf-8'); + request.addStream(new BFSE.HttpContent({ type: "application/vnd.microsoft.activity", contentLength: activityStream.length }, activityStream)); + httpContentList.forEach(e => request.addStream(e)); + + const resp = await this.streamConnection.send(request); + if (resp.streams && resp.streams.length !== 1) { + subscriber.error(new Error(`Invalid stream count ${resp.streams.length}`)); + } else { + const {Id: id} = await resp.streams[0].readAsJson(); + subscriber.next(id); + } + } catch(e) { + subscriber.error(e); + } + })(); + }); + } + + private async waitUntilOnline() { + return new Promise((resolve, reject) => { + this.connectionStatus$.subscribe((cs) => { + if (cs === ConnectionStatus.Online) return resolve(); + }, + (e) => reject(e)); + }) + } + + private async connectAsync() { + const re = new RegExp('^http(s?)'); + if (!re.test(this.domain)) throw ("Domain must begin with http or https"); + const params = {token: this.token}; + if (this.conversationId) params['conversationId'] = this.conversationId; + const urlSearchParams = new URLSearchParams(params).toString(); + const wsUrl = `${this.domain.replace(re, 'ws$1')}/conversations/connect?${urlSearchParams}`; + + return new Promise(async (resolve, reject) => { + try { + this.streamConnection = new BFSE.WebSocketClient({ + url: wsUrl, + requestHandler: this.theStreamHandler, + disconnectionHandler: (e) => resolve(e) + }); + + this.queueActivities = true; + await this.streamConnection.connect(); + const request = BFSE.StreamingRequest.create('POST', '/v3/directline/conversations'); + const response = await this.streamConnection.send(request); + if (response.statusCode !== 200) throw new Error("Connection response code " + response.statusCode); + if (response.streams.length !== 1) throw new Error("Expected 1 stream but got " + response.streams.length); + const responseString = await response.streams[0].readAsString(); + const conversation = JSON.parse(responseString); + this.conversationId = conversation.conversationId; + this.connectionStatus$.next(ConnectionStatus.Online); + + // Wait until DL consumers have had a chance to be notified + // of the connection status change. + await this.waitUntilOnline(); + this.theStreamHandler.flush(); + this.queueActivities = false; + } catch(e) { + reject(e); + } + }); + } + + private async connectWithRetryAsync() { + let numRetries = MAX_RETRY_COUNT; + while (numRetries > 0) { + numRetries--; + const start = Date.now(); + try { + this.connectionStatus$.next(ConnectionStatus.Connecting); + const res = await this.connectAsync(); + console.warn(`Retrying connection ${res}`); + if (60000 < Date.now() - start) { + // reset the retry counter and retry immediately + // if the connection lasted for more than a minute + numRetries = MAX_RETRY_COUNT; + continue; + } + } catch (err) { + console.error(`Failed to connect ${err}`); + throw(err); + } + + await new Promise(r => setTimeout(r, this.getRetryDelay())); + } + } + + // Returns the delay duration in milliseconds + private getRetryDelay() { + return Math.floor(3000 + Math.random() * 12000); + } +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index f918a99fb..c94e0a08c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,6 +6,7 @@ module.exports = { entry: { directline: './lib/directLine.js' }, + externals: ['net'], mode: 'production', output: { filename: '[name].js',