Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add networkInformation object to DLASE #412

Merged
merged 33 commits into from
Jul 11, 2023
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
774dcb2
WIP: Add watchdog
compulim Jul 5, 2023
eed0d68
Add warning if REST API returned too soon
compulim Jul 5, 2023
3e1fd3f
Add integration test
compulim Jul 6, 2023
e02817b
Add NetworkInformation watchdog
compulim Jul 6, 2023
3b8a65c
Add network information watchdog
compulim Jul 6, 2023
74945c1
Prolong test time
compulim Jul 6, 2023
1bf6c21
Support NetworkInformation-like
compulim Jul 6, 2023
dc2eda4
Add AbortSignal function story
compulim Jul 6, 2023
1b37362
Add abort watchdog test
compulim Jul 6, 2023
99c460e
Add entry
compulim Jul 6, 2023
a25f292
Update comments
compulim Jul 6, 2023
0d518d0
Add FAQ related to network change
compulim Jul 6, 2023
e1a5efc
Rename watchdog to (network) probe
compulim Jul 6, 2023
d6d78e1
Add NetworkInformationObserver
compulim Jul 9, 2023
7962ccd
Update test names
compulim Jul 9, 2023
63ab648
Add WebSocketClient implementation
compulim Jul 10, 2023
125f8a4
Add tests
compulim Jul 10, 2023
fcd2e36
Add test for change network type
compulim Jul 10, 2023
ea2437f
Move to NetworkInformation
compulim Jul 10, 2023
8cad848
Fix flaky
compulim Jul 10, 2023
7fab0f1
Clean up
compulim Jul 10, 2023
e5b0eb1
Explain NetworkInformation requirement
compulim Jul 10, 2023
39497c2
Allow unset networkInformation option
compulim Jul 10, 2023
c99fa22
Clean up
compulim Jul 10, 2023
8aacde8
Clean up
compulim Jul 11, 2023
2b2c85b
Remove NetworkInformation polyfill
compulim Jul 11, 2023
2fa1364
Update SSE
compulim Jul 11, 2023
5462abc
Update entry
compulim Jul 11, 2023
0f7a7f2
Add clarifications
compulim Jul 11, 2023
6c40e65
Update to Node.js 18
compulim Jul 11, 2023
1dd013d
Add NetworkInformation.d.ts
compulim Jul 11, 2023
00df449
Update README.md
compulim Jul 11, 2023
1dc55ae
Typo
compulim Jul 11, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Added

- Direct Line Streaming: Added `networkInformation` option to assist detection of connection issues, by [@compulim](https://github.com/compulim), in PR [#412](https://github.com/microsoft/BotFramework-DirectLineJS/pull/412)

## [0.15.4] - 2023-06-05

### Changed
Expand Down
84 changes: 48 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,23 @@

[![Build Status](https://travis-ci.org/Microsoft/BotFramework-DirectLineJS.svg?branch=master)](https://travis-ci.org/Microsoft/BotFramework-DirectLineJS)

Client library for the [Microsoft Bot Framework](http://www.botframework.com) *[Direct Line](https://docs.botframework.com/en-us/restapi/directline3/)* protocol.
Client library for the [Microsoft Bot Framework](http://www.botframework.com) _[Direct Line](https://docs.botframework.com/en-us/restapi/directline3/)_ protocol.

Used by [WebChat](https://github.com/Microsoft/BotFramework-WebChat) and thus (by extension) [Emulator](https://github.com/Microsoft/BotFramework-Emulator), WebChat channel, and [Azure Bot Service](https://azure.microsoft.com/en-us/services/bot-service/).

## FAQ

### *Who is this for?*
### _Who is this for?_

Anyone who is building a Bot Framework JavaScript client who does not want to use [WebChat](https://github.com/Microsoft/BotFramework-WebChat).

If you're currently using WebChat, you don't need to make any changes as it includes this package.

### *What is that funny `subscribe()` method in the samples below?*
### _What is that funny `subscribe()` method in the samples below?_

Instead of callbacks or Promises, this library handles async operations using Observables. Try it, you'll like it! For more information, check out [RxJS](https://github.com/reactivex/rxjs/).

### *Can I use [TypeScript](http://www.typescriptlang.com)?*
### _Can I use [TypeScript](http://www.typescriptlang.com)?_

You bet.

Expand All @@ -32,6 +32,22 @@ This is an official Microsoft-supported library, and is considered largely compl

That said, the public API is still subject to change.

### Why the library did not detect Web Socket disconnections?

On iOS/iPadOS, when network change from Wi-Fi to cellular, the `WebSocket` object will be stalled without any errors. This is not detectable nor workaroundable without any additional assistance. The issue is related to an experimental feature named "NSURLSession WebSocket". The feature is enabled by default on iOS/iPadOS 15 and up.

An option named `networkInformation` can be used to assist the library to detect any connection issues. The option is based on [W3C Network Information API](https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API) and it should implement at least 2 members:

- [A `type` property](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/type) to indicate the current network type
- When the `type` is `"offline"`, network is not available and no connection will be made
- [A `change` event](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/change_event) should dispatch when the `type` property change

However, Safari on iOS/iPadOS [does not support W3C Network Information API](https://bugs.webkit.org/show_bug.cgi?id=185697). It is up to web developers to implement the `NetworkInformation` polyfill.

One effective way to detect network type change is to subscribe to a [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) source. The service would send a message every 30 seconds. If network type changed and current network type is no longer available, the connection will be closed prematurely and an `error` event will be dispatched to the [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) instance. Upon receiving the `error` event, the `NetworkInformation.type` should then change to `"offline"`. The browser would automatically retry the Server-Sent Events connection. Upon receiving an `open` event, the polyfill should change the `type` back to `"unknown"`.

If the library is being used in a native iOS/iPadOS app, a less resource-intensive solution would be partially implementing the [Network Information API](https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API) using [`NWPathMonitor`](https://developer.apple.com/documentation/network/nwpathmonitor). When network change happens, the `NetworkInformation` instance should update the [`type` property](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/type) based on network type and dispatch a [`change` event](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/change_event).

## How to build from source

0. Clone this repo
Expand Down Expand Up @@ -88,65 +104,58 @@ var directLine = new DirectLine({
### Post activities to the bot:

```typescript
directLine.postActivity({
directLine
.postActivity({
from: { id: 'myUserId', name: 'myUserName' }, // required (from.name is optional)
type: 'message',
text: 'a message for you, Rudy'
}).subscribe(
id => console.log("Posted activity, assigned ID ", id),
error => console.log("Error posting activity", error)
);
})
.subscribe(
id => console.log('Posted activity, assigned ID ', id),
error => console.log('Error posting activity', error)
);
```

You can also post messages with attachments, and non-message activities such as events, by supplying the appropriate fields in the activity.

### Listen to activities sent from the bot:

```typescript
directLine.activity$
.subscribe(
activity => console.log("received activity ", activity)
);
directLine.activity$.subscribe(activity => console.log('received activity ', activity));
```

You can use RxJS operators on incoming activities. To see only message activities:

```typescript
directLine.activity$
.filter(activity => activity.type === 'message')
.subscribe(
message => console.log("received message ", message)
);
.filter(activity => activity.type === 'message')
.subscribe(message => console.log('received message ', message));
```

Direct Line will helpfully send your client a copy of every sent activity, so a common pattern is to filter incoming messages on `from`:

```typescript
directLine.activity$
.filter(activity => activity.type === 'message' && activity.from.id === 'yourBotHandle')
.subscribe(
message => console.log("received message ", message)
);
.filter(activity => activity.type === 'message' && activity.from.id === 'yourBotHandle')
.subscribe(message => console.log('received message ', message));
```

### Monitor connection status

Subscribing to either `postActivity` or `activity$` will start the process of connecting to the bot. Your app can listen to the connection status and react appropriately :

```typescript

import { ConnectionStatus } from 'botframework-directlinejs';

directLine.connectionStatus$
.subscribe(connectionStatus => {
switch(connectionStatus) {
case ConnectionStatus.Uninitialized: // the status when the DirectLine object is first created/constructed
case ConnectionStatus.Connecting: // currently trying to connect to the conversation
case ConnectionStatus.Online: // successfully connected to the converstaion. Connection is healthy so far as we know.
case ConnectionStatus.ExpiredToken: // last operation errored out with an expired token. Your app should supply a new one.
case ConnectionStatus.FailedToConnect: // the initial attempt to connect to the conversation failed. No recovery possible.
case ConnectionStatus.Ended: // the bot ended the conversation
}
directLine.connectionStatus$.subscribe(connectionStatus => {
switch (connectionStatus) {
case ConnectionStatus.Uninitialized: // the status when the DirectLine object is first created/constructed
case ConnectionStatus.Connecting: // currently trying to connect to the conversation
case ConnectionStatus.Online: // successfully connected to the converstaion. Connection is healthy so far as we know.
case ConnectionStatus.ExpiredToken: // last operation errored out with an expired token. Your app should supply a new one.
case ConnectionStatus.FailedToConnect: // the initial attempt to connect to the conversation failed. No recovery possible.
case ConnectionStatus.Ended: // the bot ended the conversation
}
});
```

Expand All @@ -168,7 +177,8 @@ directLine.reconnect(conversation);
When using DirectLine with WebChat, closing the current tab or refreshing the page will create a new conversation in most cases. You can resume an existing conversation to keep the user in the same context.

**When using a secret** you can resume a conversation by:
- Storing the conversationid (in a *permanent* place, like local storage)

- Storing the conversationid (in a _permanent_ place, like local storage)
- Giving this value back while creating the DirectLine object along with the secret

```typescript
Expand All @@ -181,7 +191,8 @@ const dl = new DirectLine({
```

**When using a token** you can resume a conversation by:
- Storing the conversationid and your token (in a *permanent* place, like local storage)

- Storing the conversationid and your token (in a _permanent_ place, like local storage)
- Calling the DirectLine reconnect API yourself to get a refreshed token and a streamurl
- Creating the DirectLine object using the ConversationId, Token, and StreamUrl

Expand All @@ -196,7 +207,7 @@ const dl = new DirectLine({
```

**Getting any history that Direct Line has cached** : you can retrieve history using watermarks:
You can see the watermark as an *activity 'bookmark'*. The resuming scenario will replay all the conversation activities from the watermark you specify.
You can see the watermark as an _activity 'bookmark'_. The resuming scenario will replay all the conversation activities from the watermark you specify.

```typescript
import { DirectLine } from 'botframework-directlinejs';
Expand All @@ -212,7 +223,7 @@ const dl = new DirectLine({

## Contributing

This project welcomes contributions and suggestions. Most contributions require you to agree to a
This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit https://cla.microsoft.com.

Expand All @@ -228,6 +239,7 @@ For more information see the [Code of Conduct FAQ](https://opensource.microsoft.
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.

## Reporting Security Issues

Security issues and bugs should be reported privately, via email, to the Microsoft Security Response Center (MSRC) at [secure@microsoft.com](mailto:secure@microsoft.com). You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Further information, including the [MSRC PGP](https://technet.microsoft.com/en-us/security/dn606155) key, can be found in the [Security TechCenter](https://technet.microsoft.com/en-us/security/default).

Copyright (c) Microsoft Corporation. All rights reserved.
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export default function createBotProxy(init?: CreateBotProxyInit): Promise<Creat
webSocketProxy.emit('connection', ws, proxySocket, req)
)
);
proxySocket.addEventListener('error', () => {});

socket.once('close', () => proxySocket.close());
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import '@jest/types';

declare global {
namespace jest {
interface Expect {
activityContaining(messageText: string, mergeActivity?: { id?: string; type?: string }): any;
}
}
}
15 changes: 10 additions & 5 deletions __tests__/directLineStreaming/__setup__/mockObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,24 @@ type Observation<T> =
* Mocks an observer and records all observations.
*/
export default function mockObserver<T>(): Readonly<
Required<Observer<T>> & { observations: ReadonlyArray<Observation<T>> }
Required<Observer<T>> & {
observations: ReadonlyArray<Observation<T>>;
observe: (observation: Observation<T>) => void;
}
> {
const observe: (observation: Observation<T>) => void = jest.fn(observation => observations.push(observation));
const observations: Array<Observation<T>> = [];

const complete = jest.fn(() => observations.push([Date.now(), 'complete']));
const error = jest.fn(reason => observations.push([Date.now(), 'error', reason]));
const next = jest.fn(value => observations.push([Date.now(), 'next', value]));
const start = jest.fn(subscription => observations.push([Date.now(), 'start', subscription]));
const complete = jest.fn(() => observe([Date.now(), 'complete']));
const error = jest.fn(reason => observe([Date.now(), 'error', reason]));
const next = jest.fn(value => observe([Date.now(), 'next', value]));
const start = jest.fn(subscription => observe([Date.now(), 'start', subscription]));

return Object.freeze({
complete,
error,
next,
observe,
start,

get observations() {
Expand Down
2 changes: 1 addition & 1 deletion __tests__/directLineStreaming/connect.fail.story.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ test('connect fail should signal properly', async () => {
directLine.activity$.subscribe(activityObserver);

// THEN: Should try to connect 3 times.
await waitFor(() => expect(onUpgrade).toBeCalledTimes(3));
await waitFor(() => expect(onUpgrade).toBeCalledTimes(3), { timeout: 5_000 });

// THEN: Should not wait before connecting the first time.
expect(onUpgrade.mock.results[0].value - connectTime).toBeLessThan(3000);
Expand Down
136 changes: 136 additions & 0 deletions __tests__/directLineStreaming/options.networkInformation.story.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/// <reference path="./__setup__/expect/activityContaining.d.ts" />

import fetch from 'node-fetch';

import { ConnectionStatus } from '../../src/directLine';
import { DirectLineStreaming } from '../../src/directLineStreaming';
import mockObserver from './__setup__/mockObserver';
import setupBotProxy from './__setup__/setupBotProxy';
import waitFor from './__setup__/external/testing-library/waitFor';

type MockObserver<T> = ReturnType<typeof mockObserver>;
type ResultOfPromise<T> = T extends PromiseLike<infer P> ? P : never;

const MOCKBOT3_URL = 'https://webchat-mockbot3.azurewebsites.net/';
const TOKEN_URL = 'https://webchat-mockbot3.azurewebsites.net/api/token/directlinease';

jest.setTimeout(10_000);

// GIVEN: A Direct Line Streaming chat adapter with Network Information API.
describe('Direct Line Streaming chat adapter with Network Information API', () => {
let activityObserver: MockObserver<any>;
let botProxy: ResultOfPromise<ReturnType<typeof setupBotProxy>>;
let connectionStatusObserver: MockObserver<ConnectionStatus>;
let directLine: DirectLineStreaming;

beforeEach(async () => {
jest.useFakeTimers({ now: 0 });

const networkInformation = new EventTarget();
let type: string = 'wifi';

Object.defineProperty(networkInformation, 'type', {
get() {
return type;
},
set(value: string) {
if (type !== value) {
type = value;
networkInformation.dispatchEvent(new Event('change'));
}
}
});

(global as any).navigator = {
get connection() {
return networkInformation;
}
};

let token: string;

[botProxy, { token }] = await Promise.all([
setupBotProxy({ streamingBotURL: MOCKBOT3_URL }),
fetch(TOKEN_URL, { method: 'POST' }).then(res => res.json())
]);

activityObserver = mockObserver();
connectionStatusObserver = mockObserver();
directLine = new DirectLineStreaming({
domain: botProxy.directLineStreamingURL,
networkInformation: navigator.connection,
token
});

directLine.connectionStatus$.subscribe(connectionStatusObserver);
});

afterEach(() => {
directLine.end();

jest.useRealTimers();
});

describe('when connect', () => {
// WHEN: Connect.
beforeEach(() => directLine.activity$.subscribe(activityObserver));

// THEN: Should observe "Uninitialized" -> "Connecting" -> "Online".
test('should observe "Uninitialized" -> "Connecting" -> "Online"', () =>
waitFor(
() =>
expect(connectionStatusObserver).toHaveProperty('observations', [
[expect.any(Number), 'next', ConnectionStatus.Uninitialized],
[expect.any(Number), 'next', ConnectionStatus.Connecting],
[expect.any(Number), 'next', ConnectionStatus.Online]
]),
{ timeout: 5_000 }
));

// WHEN: Connection status become "Online".
describe('after online', () => {
beforeEach(() =>
waitFor(
() =>
expect(connectionStatusObserver.observe).toHaveBeenLastCalledWith([
expect.any(Number),
'next',
ConnectionStatus.Online
]),
{ timeout: 5_000 }
)
);

// THEN: Should receive "Hello and welcome!"
test('should receive "Hello and welcome!"', () =>
waitFor(
() =>
expect(activityObserver).toHaveProperty('observations', [
[expect.any(Number), 'next', expect.activityContaining('Hello and welcome!')]
]),
{ timeout: 5_000 }
));

// WHEN: "change" event is received.
describe('when "change" event is received', () => {
beforeEach(() => {
(navigator.connection as any).type = 'bluetooth';
});

// THEN: Should observe "Connecting" -> "Online" again.
test('should observe ... -> "Connecting" -> "Online"', () =>
waitFor(
() =>
expect(connectionStatusObserver).toHaveProperty('observations', [
[expect.any(Number), 'next', ConnectionStatus.Uninitialized],
[expect.any(Number), 'next', ConnectionStatus.Connecting],
[expect.any(Number), 'next', ConnectionStatus.Online],
[expect.any(Number), 'next', ConnectionStatus.Connecting],
[expect.any(Number), 'next', ConnectionStatus.Online]
]),
{ timeout: 5_000 }
));
});
});
});
});
Loading
Loading