├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── config ├── tslint │ └── src.json ├── lint-staged │ └── config.json ├── eslint │ ├── src.json │ ├── config.json │ └── test.json ├── prettier │ └── config.json ├── rollup │ └── bundle.mjs └── karma │ ├── config-unit.js │ └── config-integration.js ├── src ├── types │ ├── get-typed-keys-function.ts │ ├── web-socket-observer-factory.ts │ ├── binary-type.ts │ ├── event-handler.ts │ ├── data-channel-observer-factory.ts │ ├── stringifyable-json-object.ts │ ├── masked-subject-factory-factory.ts │ ├── transport-observable-factory.ts │ ├── web-socket-subject-factory.ts │ ├── data-channel-subject-factory.ts │ ├── stringifyable-json-value.ts │ ├── web-socket-subject-factory-factory.ts │ ├── data-channel-subject-factory-factory.ts │ ├── masked-subject-factory.ts │ └── index.ts ├── tsconfig.json ├── interfaces │ ├── stringifyable.ts │ ├── index.ts │ ├── stringifyable-json-object.ts │ ├── subject-config.ts │ └── remote-subject.ts ├── functions │ └── get-typed-keys.ts ├── factories │ ├── web-socket-observer.ts │ ├── data-channel-observer.ts │ ├── transport-observable.ts │ ├── web-socket-subject-factory.ts │ ├── data-channel-subject-factory.ts │ └── masked-subject-factory.ts ├── classes │ ├── web-socket-subject.ts │ ├── data-channel-subject.ts │ ├── transport-observable.ts │ ├── masked-subject.ts │ ├── web-socket-observer.ts │ └── data-channel-observer.ts └── module.ts ├── test ├── unit │ ├── factories │ │ └── transport-observable.js │ └── classes │ │ ├── web-socket-subject.js │ │ ├── masked-subject.js │ │ ├── data-channel-subject.js │ │ ├── transport-observable.js │ │ ├── data-channel-observer.js │ │ └── web-socket-observer.js ├── mock │ ├── web-socket.js │ └── data-channel.js ├── helper │ └── establish-data-channels.js └── integration │ └── module.js ├── LICENSE ├── .github └── workflows │ └── test.yml ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | node_modules/ 3 | /build/ 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | commitlint --edit --extends @commitlint/config-angular 2 | -------------------------------------------------------------------------------- /config/tslint/src.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-holy-grail" 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged --config config/lint-staged/config.json && npm run lint 2 | -------------------------------------------------------------------------------- /src/types/get-typed-keys-function.ts: -------------------------------------------------------------------------------- 1 | export type TGetTypedKeysFunction = (object: T) => (keyof T)[]; 2 | -------------------------------------------------------------------------------- /config/lint-staged/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": "prettier --config config/prettier/config.json --ignore-unknown --write" 3 | } 4 | -------------------------------------------------------------------------------- /config/eslint/src.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": "eslint-config-holy-grail" 6 | } 7 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "isolatedModules": true 4 | }, 5 | "extends": "tsconfig-holy-grail/src/tsconfig-browser" 6 | } 7 | -------------------------------------------------------------------------------- /src/interfaces/stringifyable.ts: -------------------------------------------------------------------------------- 1 | import { TStringifyableJsonObject } from '../types'; 2 | 3 | export interface IStringifyable { 4 | toJSON(): TStringifyableJsonObject; 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './remote-subject'; 2 | export * from './stringifyable-json-object'; 3 | export * from './stringifyable'; 4 | export * from './subject-config'; 5 | -------------------------------------------------------------------------------- /src/interfaces/stringifyable-json-object.ts: -------------------------------------------------------------------------------- 1 | import { TStringifyableJsonValue } from '../types'; 2 | 3 | export interface IStringifyableJsonObject { 4 | [key: string]: TStringifyableJsonValue; 5 | } 6 | -------------------------------------------------------------------------------- /src/functions/get-typed-keys.ts: -------------------------------------------------------------------------------- 1 | import { TGetTypedKeysFunction } from '../types'; 2 | 3 | export const getTypedKeys: TGetTypedKeysFunction = (object: T) => <(keyof T)[]>Object.keys(object); 4 | -------------------------------------------------------------------------------- /src/types/web-socket-observer-factory.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketObserver } from '../classes/web-socket-observer'; 2 | 3 | export type TWebSocketObserverFactory = (webSocket: WebSocket) => WebSocketObserver; 4 | -------------------------------------------------------------------------------- /src/types/binary-type.ts: -------------------------------------------------------------------------------- 1 | // @todo This is an extract of @types/webrtc because it can't be used anymore since it causes a conflict with the built-in types. 2 | 3 | export type TBinaryType = 'arraybuffer' | 'blob'; 4 | -------------------------------------------------------------------------------- /config/prettier/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 140, 4 | "quoteProps": "consistent", 5 | "singleQuote": true, 6 | "tabWidth": 4, 7 | "trailingComma": "none" 8 | } 9 | -------------------------------------------------------------------------------- /src/types/event-handler.ts: -------------------------------------------------------------------------------- 1 | // @todo This is an extract of @types/webrtc because it can't be used anymore since it causes a conflict with the built-in types. 2 | 3 | export type TEventHandler = (event: Event) => void; 4 | -------------------------------------------------------------------------------- /src/types/data-channel-observer-factory.ts: -------------------------------------------------------------------------------- 1 | import { DataChannelObserver } from '../classes/data-channel-observer'; 2 | 3 | export type TDataChannelObserverFactory = (dataChannel: RTCDataChannel) => DataChannelObserver; 4 | -------------------------------------------------------------------------------- /src/interfaces/subject-config.ts: -------------------------------------------------------------------------------- 1 | import { NextObserver } from 'rxjs'; 2 | 3 | export interface ISubjectConfig { 4 | openObserver?: NextObserver; 5 | 6 | deserializer?(event: MessageEvent): T; 7 | } 8 | -------------------------------------------------------------------------------- /src/factories/web-socket-observer.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketObserver } from '../classes/web-socket-observer'; 2 | 3 | export const createWebSocketObserver = (webSocket: WebSocket) => { 4 | return new WebSocketObserver(webSocket); 5 | }; 6 | -------------------------------------------------------------------------------- /config/eslint/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "extends": "eslint-config-holy-grail", 6 | "rules": { 7 | "no-sync": "off", 8 | "node/no-missing-require": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/types/stringifyable-json-object.ts: -------------------------------------------------------------------------------- 1 | import { IStringifyableJsonObject } from '../interfaces'; 2 | 3 | export type TStringifyableJsonObject = { 4 | [P in keyof T]: T[P]; 5 | }; 6 | -------------------------------------------------------------------------------- /src/factories/data-channel-observer.ts: -------------------------------------------------------------------------------- 1 | import { DataChannelObserver } from '../classes/data-channel-observer'; 2 | 3 | export const createDataChannelObserver = (dataChannel: RTCDataChannel) => { 4 | return new DataChannelObserver(dataChannel); 5 | }; 6 | -------------------------------------------------------------------------------- /src/interfaces/remote-subject.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | import { TStringifyableJsonValue } from '../types'; 3 | 4 | export interface IRemoteSubject extends Subject { 5 | close(): void; 6 | 7 | send(message: T): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /src/types/masked-subject-factory-factory.ts: -------------------------------------------------------------------------------- 1 | import { TGetTypedKeysFunction } from './get-typed-keys-function'; 2 | import { TMaskedSubjectFactory } from './masked-subject-factory'; 3 | 4 | export type TMaskedSubjectFactoryFactory = (getTypedKeys: TGetTypedKeysFunction) => TMaskedSubjectFactory; 5 | -------------------------------------------------------------------------------- /src/types/transport-observable-factory.ts: -------------------------------------------------------------------------------- 1 | import { TransportObservable } from '../classes/transport-observable'; 2 | import { ISubjectConfig } from '../interfaces'; 3 | 4 | export type TTransportObservableFactory = ( 5 | transport: T, 6 | subjectConfig: ISubjectConfig 7 | ) => TransportObservable; 8 | -------------------------------------------------------------------------------- /src/factories/transport-observable.ts: -------------------------------------------------------------------------------- 1 | import { TransportObservable } from '../classes/transport-observable'; 2 | import { ISubjectConfig } from '../interfaces'; 3 | 4 | export const createTransportObservable = (transport: T, subjectConfig: ISubjectConfig) => { 5 | return new TransportObservable(transport, subjectConfig); 6 | }; 7 | -------------------------------------------------------------------------------- /test/unit/factories/transport-observable.js: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { createTransportObservable } from '../../../src/factories/transport-observable'; 3 | 4 | describe('createTransportObservable()', () => { 5 | it('should return an Observable', () => { 6 | expect(createTransportObservable({}, {})).to.be.an.instanceOf(Observable); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /config/eslint/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "mocha": true 5 | }, 6 | "extends": "eslint-config-holy-grail", 7 | "globals": { 8 | "expect": "readonly" 9 | }, 10 | "rules": { 11 | "no-unused-expressions": "off", 12 | "node/file-extension-in-import": "off", 13 | "node/no-missing-require": "off" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/types/web-socket-subject-factory.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketSubject } from '../classes/web-socket-subject'; 2 | import { ISubjectConfig } from '../interfaces'; 3 | import { TStringifyableJsonValue } from './stringifyable-json-value'; 4 | 5 | export type TWebSocketSubjectFactory = ( 6 | webSocket: WebSocket, 7 | subjectConfig: ISubjectConfig 8 | ) => WebSocketSubject; 9 | -------------------------------------------------------------------------------- /src/types/data-channel-subject-factory.ts: -------------------------------------------------------------------------------- 1 | import { DataChannelSubject } from '../classes/data-channel-subject'; 2 | import { ISubjectConfig } from '../interfaces'; 3 | import { TStringifyableJsonValue } from './stringifyable-json-value'; 4 | 5 | export type TDataChannelSubjectFactory = ( 6 | dataChannel: RTCDataChannel, 7 | subjectConfig: ISubjectConfig 8 | ) => DataChannelSubject; 9 | -------------------------------------------------------------------------------- /src/types/stringifyable-json-value.ts: -------------------------------------------------------------------------------- 1 | import { IStringifyable } from '../interfaces'; 2 | import { TStringifyableJsonObject } from './stringifyable-json-object'; 3 | 4 | export type TStringifyableJsonValue = 5 | | boolean // tslint:disable-line:no-null-undefined-union 6 | | null 7 | | number 8 | | string 9 | | undefined 10 | | IStringifyable 11 | | TStringifyableJsonObject 12 | | TStringifyableJsonValue[]; 13 | -------------------------------------------------------------------------------- /src/types/web-socket-subject-factory-factory.ts: -------------------------------------------------------------------------------- 1 | import { TTransportObservableFactory } from './transport-observable-factory'; 2 | import { TWebSocketObserverFactory } from './web-socket-observer-factory'; 3 | import { TWebSocketSubjectFactory } from './web-socket-subject-factory'; 4 | 5 | export type TWebSocketSubjectFactoryFactory = ( 6 | createTransportObservable: TTransportObservableFactory, 7 | createWebSocketObserver: TWebSocketObserverFactory 8 | ) => TWebSocketSubjectFactory; 9 | -------------------------------------------------------------------------------- /src/types/data-channel-subject-factory-factory.ts: -------------------------------------------------------------------------------- 1 | import { TDataChannelObserverFactory } from './data-channel-observer-factory'; 2 | import { TDataChannelSubjectFactory } from './data-channel-subject-factory'; 3 | import { TTransportObservableFactory } from './transport-observable-factory'; 4 | 5 | export type TDataChannelSubjectFactoryFactory = ( 6 | createDataChannelObserver: TDataChannelObserverFactory, 7 | createTransportObservable: TTransportObservableFactory 8 | ) => TDataChannelSubjectFactory; 9 | -------------------------------------------------------------------------------- /src/factories/web-socket-subject-factory.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketSubject } from '../classes/web-socket-subject'; 2 | import { ISubjectConfig } from '../interfaces'; 3 | import { TStringifyableJsonValue, TWebSocketSubjectFactoryFactory } from '../types'; 4 | 5 | export const createWebSocketSubjectFactory: TWebSocketSubjectFactoryFactory = (createTransportObservable, createWebSocketObserver) => { 6 | return (webSocket: WebSocket, subjectConfig: ISubjectConfig) => { 7 | return new WebSocketSubject(createTransportObservable, createWebSocketObserver, webSocket, subjectConfig); 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/types/masked-subject-factory.ts: -------------------------------------------------------------------------------- 1 | import { IRemoteSubject } from '../interfaces'; 2 | import { TStringifyableJsonObject } from './stringifyable-json-object'; 3 | import { TStringifyableJsonValue } from './stringifyable-json-value'; 4 | 5 | export type TMaskedSubjectFactory = < 6 | T extends TStringifyableJsonValue, 7 | U extends TStringifyableJsonObject & { message: T } = TStringifyableJsonObject & { message: T }, 8 | V extends TStringifyableJsonObject | U = TStringifyableJsonObject | U 9 | >( // tslint:disable-line max-line-length 10 | mask: Partial>, 11 | maskableSubject: IRemoteSubject 12 | ) => IRemoteSubject; 13 | -------------------------------------------------------------------------------- /src/factories/data-channel-subject-factory.ts: -------------------------------------------------------------------------------- 1 | import { DataChannelSubject } from '../classes/data-channel-subject'; 2 | import { ISubjectConfig } from '../interfaces'; 3 | import { TDataChannelSubjectFactoryFactory, TStringifyableJsonValue } from '../types'; 4 | 5 | export const createDataChannelSubjectFactory: TDataChannelSubjectFactoryFactory = ( 6 | createDataChannelObserver, 7 | createTransportObservable 8 | ) => { 9 | return (dataChannel: RTCDataChannel, subjectConfig: ISubjectConfig) => { 10 | return new DataChannelSubject(createDataChannelObserver, createTransportObservable, dataChannel, subjectConfig); 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './binary-type'; 2 | export * from './data-channel-observer-factory'; 3 | export * from './data-channel-subject-factory'; 4 | export * from './data-channel-subject-factory-factory'; 5 | export * from './event-handler'; 6 | export * from './get-typed-keys-function'; 7 | export * from './masked-subject-factory'; 8 | export * from './masked-subject-factory-factory'; 9 | export * from './stringifyable-json-object'; 10 | export * from './stringifyable-json-value'; 11 | export * from './transport-observable-factory'; 12 | export * from './web-socket-observer-factory'; 13 | export * from './web-socket-subject-factory'; 14 | export * from './web-socket-subject-factory-factory'; 15 | -------------------------------------------------------------------------------- /config/rollup/bundle.mjs: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | 3 | // eslint-disable-next-line import/no-default-export 4 | export default { 5 | input: 'build/es2019/module.js', 6 | output: { 7 | file: 'build/es5/bundle.js', 8 | format: 'umd', 9 | name: 'rxjsBroker' 10 | }, 11 | plugins: [ 12 | babel({ 13 | babelHelpers: 'runtime', 14 | exclude: 'node_modules/**', 15 | plugins: ['@babel/plugin-external-helpers', '@babel/plugin-transform-runtime'], 16 | presets: [ 17 | [ 18 | '@babel/preset-env', 19 | { 20 | modules: false 21 | } 22 | ] 23 | ] 24 | }) 25 | ] 26 | }; 27 | -------------------------------------------------------------------------------- /src/factories/masked-subject-factory.ts: -------------------------------------------------------------------------------- 1 | import { MaskedSubject } from '../classes/masked-subject'; 2 | import { IRemoteSubject } from '../interfaces'; 3 | import { TMaskedSubjectFactoryFactory, TStringifyableJsonObject, TStringifyableJsonValue } from '../types'; 4 | 5 | export const createMaskedSubjectFactory: TMaskedSubjectFactoryFactory = (getTypedKeys) => { 6 | return < 7 | T extends TStringifyableJsonValue, 8 | U extends TStringifyableJsonObject & { message: T } = TStringifyableJsonObject & { message: T }, 9 | V extends TStringifyableJsonObject | U = TStringifyableJsonObject | U 10 | >( // tslint:disable-line max-line-length 11 | mask: Partial>, 12 | maskableSubject: IRemoteSubject 13 | ) => { 14 | return new MaskedSubject(getTypedKeys, mask, maskableSubject); 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /test/mock/web-socket.js: -------------------------------------------------------------------------------- 1 | import { spy, stub } from 'sinon'; 2 | 3 | // @todo This is an obviously imperfect implementation of the EventTarget. 4 | export class WebSocketMock { 5 | constructor() { 6 | const events = new Map(); 7 | 8 | this.addEventListener = (type, listener) => { 9 | if (events.has(type)) { 10 | events.get(type).add(listener); 11 | } else { 12 | events.set(type, new Set([listener])); 13 | } 14 | }; 15 | this.close = spy(); 16 | this.dispatchEvent = (event) => { 17 | if (events.has(event.type)) { 18 | events.get(event.type).forEach((listener) => { 19 | listener(event); 20 | }); 21 | } 22 | }; 23 | this.removeEventListener = stub(); 24 | this.send = spy(); 25 | 26 | this.removeEventListener.callsFake((type, listener) => { 27 | if (events.has(type)) { 28 | events.get(type).delete(listener); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Christoph Guttandin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/mock/data-channel.js: -------------------------------------------------------------------------------- 1 | import { spy, stub } from 'sinon'; 2 | 3 | // @todo This is an obviously imperfect implementation of the EventTarget. 4 | export class DataChannelMock { 5 | constructor() { 6 | const events = new Map(); 7 | 8 | this.addEventListener = (type, listener) => { 9 | if (events.has(type)) { 10 | events.get(type).add(listener); 11 | } else { 12 | events.set(type, new Set([listener])); 13 | } 14 | }; 15 | this.bufferedAmount = 0; 16 | this.bufferedAmountLowThreshold = 0; 17 | this.close = spy(); 18 | this.dispatchEvent = (event) => { 19 | if (events.has(event.type)) { 20 | events.get(event.type).forEach((listener) => { 21 | listener(event); 22 | }); 23 | } 24 | }; 25 | this.removeEventListener = stub(); 26 | this.send = spy(); 27 | 28 | this.removeEventListener.callsFake((type, listener) => { 29 | if (events.has(type)) { 30 | events.get(type).delete(listener); 31 | } 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/helper/establish-data-channels.js: -------------------------------------------------------------------------------- 1 | export const establishDataChannels = () => { 2 | const localPeerConnection = new RTCPeerConnection(); 3 | const remotePeerConnection = new RTCPeerConnection(); 4 | 5 | localPeerConnection.onicecandidate = ({ candidate }) => remotePeerConnection.addIceCandidate(candidate); 6 | remotePeerConnection.onicecandidate = ({ candidate }) => localPeerConnection.addIceCandidate(candidate); 7 | 8 | const localDataChannel = localPeerConnection.createDataChannel('channel', { id: 0, negotiated: true }); 9 | const remoteDataChannel = remotePeerConnection.createDataChannel('channel', { id: 0, negotiated: true }); 10 | 11 | remotePeerConnection 12 | .createOffer() 13 | .then((offerDescription) => { 14 | remotePeerConnection.setLocalDescription(offerDescription); 15 | localPeerConnection.setRemoteDescription(offerDescription); 16 | 17 | return localPeerConnection.createAnswer(); 18 | }) 19 | .then((answerDescription) => { 20 | localPeerConnection.setLocalDescription(answerDescription); 21 | 22 | remotePeerConnection.setRemoteDescription(answerDescription); 23 | }); 24 | 25 | return { localDataChannel, remoteDataChannel }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/classes/web-socket-subject.ts: -------------------------------------------------------------------------------- 1 | import { AnonymousSubject } from 'rxjs/internal/Subject'; // tslint:disable-line rxjs-no-compat no-submodule-imports rxjs-no-internal 2 | import { IRemoteSubject, ISubjectConfig } from '../interfaces'; 3 | import { TStringifyableJsonValue, TTransportObservableFactory, TWebSocketObserverFactory } from '../types'; 4 | 5 | export class WebSocketSubject extends AnonymousSubject implements IRemoteSubject { 6 | private _webSocket: WebSocket; 7 | 8 | constructor( 9 | createTransportObservable: TTransportObservableFactory, 10 | createWebSocketObserver: TWebSocketObserverFactory, 11 | webSocket: WebSocket, 12 | subjectConfig: ISubjectConfig 13 | ) { 14 | const observable = createTransportObservable(webSocket, subjectConfig); 15 | const observer = createWebSocketObserver(webSocket); 16 | 17 | super(observer, observable); 18 | 19 | this._webSocket = webSocket; 20 | } 21 | 22 | public close(): void { 23 | this._webSocket.close(); 24 | } 25 | 26 | public send(message: T): Promise { 27 | const { destination }: any = this; 28 | 29 | if (!this.isStopped) { 30 | return destination.send(message); 31 | } 32 | 33 | return Promise.resolve(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/classes/data-channel-subject.ts: -------------------------------------------------------------------------------- 1 | import { AnonymousSubject } from 'rxjs/internal/Subject'; // tslint:disable-line rxjs-no-compat no-submodule-imports rxjs-no-internal 2 | import { IRemoteSubject, ISubjectConfig } from '../interfaces'; 3 | import { TDataChannelObserverFactory, TStringifyableJsonValue, TTransportObservableFactory } from '../types'; 4 | 5 | export class DataChannelSubject extends AnonymousSubject implements IRemoteSubject { 6 | private _dataChannel: RTCDataChannel; 7 | 8 | constructor( 9 | createDataChannelObserver: TDataChannelObserverFactory, 10 | createTransportObservable: TTransportObservableFactory, 11 | dataChannel: RTCDataChannel, 12 | subjectConfig: ISubjectConfig 13 | ) { 14 | const observable = createTransportObservable(dataChannel, subjectConfig); 15 | const observer = createDataChannelObserver(dataChannel); 16 | 17 | super(observer, observable); 18 | 19 | this._dataChannel = dataChannel; 20 | } 21 | 22 | public close(): void { 23 | this._dataChannel.close(); 24 | } 25 | 26 | public send(message: T): Promise { 27 | const { destination }: any = this; 28 | 29 | if (!this.isStopped) { 30 | return destination.send(message); 31 | } 32 | 33 | return Promise.resolve(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [20.x] 18 | target: [chrome, firefox, safari] 19 | type: [integration, unit] 20 | max-parallel: 3 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v6 25 | 26 | - name: Install Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v6 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | 31 | - name: Cache node modules 32 | uses: actions/cache@v5 33 | with: 34 | path: ~/.npm 35 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 36 | restore-keys: | 37 | ${{ runner.os }}-node- 38 | 39 | - name: Install dependencies 40 | run: npm ci 41 | 42 | - env: 43 | BROWSER_STACK_ACCESS_KEY: ${{ secrets.BROWSER_STACK_ACCESS_KEY }} 44 | BROWSER_STACK_USERNAME: ${{ secrets.BROWSER_STACK_USERNAME }} 45 | TARGET: ${{ matrix.target }} 46 | TYPE: ${{ matrix.type }} 47 | name: Run ${{ matrix.type }} tests 48 | run: npm test 49 | -------------------------------------------------------------------------------- /src/classes/transport-observable.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { ISubjectConfig } from '../interfaces'; 3 | 4 | const defaultDeserializer = ({ data }: MessageEvent) => { 5 | try { 6 | return JSON.parse(data); 7 | } catch { 8 | return data; 9 | } 10 | }; 11 | 12 | // tslint:disable-next-line rxjs-no-subclass 13 | export class TransportObservable extends Observable { 14 | constructor(transport: T, { deserializer = defaultDeserializer, openObserver }: ISubjectConfig) { 15 | super((observer) => { 16 | const handleCloseEvent = () => observer.complete(); 17 | const handleErrorEvent = ( 18 | (({ error }: ErrorEvent) => (error === undefined ? observer.error(new Error('Unknown Error')) : observer.error(error))) 19 | ); 20 | const handleMessageEvent = ((event: MessageEvent) => observer.next(deserializer(event))); 21 | const handleOpenEvent = () => { 22 | if (openObserver !== undefined) { 23 | openObserver.next(); 24 | } 25 | }; 26 | 27 | transport.addEventListener('close', handleCloseEvent); 28 | transport.addEventListener('error', handleErrorEvent); 29 | transport.addEventListener('message', handleMessageEvent); 30 | transport.addEventListener('open', handleOpenEvent); 31 | 32 | return () => { 33 | transport.removeEventListener('close', handleCloseEvent); 34 | transport.removeEventListener('error', handleErrorEvent); 35 | transport.removeEventListener('message', handleMessageEvent); 36 | transport.removeEventListener('open', handleOpenEvent); 37 | }; 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { createDataChannelObserver } from './factories/data-channel-observer'; 2 | import { createDataChannelSubjectFactory } from './factories/data-channel-subject-factory'; 3 | import { createMaskedSubjectFactory } from './factories/masked-subject-factory'; 4 | import { createTransportObservable } from './factories/transport-observable'; 5 | import { createWebSocketObserver } from './factories/web-socket-observer'; 6 | import { createWebSocketSubjectFactory } from './factories/web-socket-subject-factory'; 7 | import { getTypedKeys } from './functions/get-typed-keys'; 8 | import { IRemoteSubject, ISubjectConfig } from './interfaces'; 9 | import { TStringifyableJsonValue } from './types'; 10 | 11 | /* 12 | * @todo Explicitly referencing the barrel file seems to be necessary when enabling the 13 | * isolatedModules compiler option. 14 | */ 15 | export * from './interfaces/index'; 16 | export * from './types/index'; 17 | 18 | const createDataChannelSubject = createDataChannelSubjectFactory(createDataChannelObserver, createTransportObservable); 19 | const createWebSocketSubject = createWebSocketSubjectFactory(createTransportObservable, createWebSocketObserver); 20 | 21 | export const connect = (url: string, subjectConfig: ISubjectConfig = {}): IRemoteSubject => { 22 | return createWebSocketSubject(new WebSocket(url), subjectConfig); 23 | }; 24 | 25 | /** 26 | * This property is true if the browser supports WebSockets. 27 | */ 28 | export const isSupported = typeof window !== 'undefined' && 'WebSocket' in window; 29 | 30 | export const mask = createMaskedSubjectFactory(getTypedKeys); 31 | 32 | export const wrap = ( 33 | dataChannel: RTCDataChannel, 34 | subjectConfig: ISubjectConfig = {} 35 | ): IRemoteSubject => { 36 | return createDataChannelSubject(dataChannel, subjectConfig); 37 | }; 38 | -------------------------------------------------------------------------------- /src/classes/masked-subject.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Observer } from 'rxjs'; 2 | import { AnonymousSubject } from 'rxjs/internal/Subject'; // tslint:disable-line rxjs-no-compat no-submodule-imports rxjs-no-internal 3 | import { filter, map } from 'rxjs/operators'; 4 | import { IRemoteSubject } from '../interfaces'; 5 | import { TGetTypedKeysFunction, TStringifyableJsonObject, TStringifyableJsonValue } from '../types'; 6 | 7 | export class MaskedSubject< 8 | T extends TStringifyableJsonValue, 9 | U extends TStringifyableJsonObject & { message: T }, 10 | V extends TStringifyableJsonObject | U 11 | > 12 | extends AnonymousSubject 13 | implements IRemoteSubject 14 | { 15 | // tslint:disable-line max-line-length 16 | private _mask: Partial>; 17 | 18 | // tslint:disable-next-line rxjs-no-exposed-subjects 19 | private _maskableSubject: IRemoteSubject; 20 | 21 | // tslint:disable-next-line max-line-length rxjs-no-exposed-subjects 22 | constructor(getTypedKeys: TGetTypedKeysFunction, mask: Partial>, maskableSubject: IRemoteSubject) { 23 | const destination: Observer = { 24 | complete: maskableSubject.complete, 25 | error: maskableSubject.error, 26 | next: (value) => this.send(value) 27 | }; 28 | 29 | const stringifiedValues = getTypedKeys(mask).map((key) => [key, JSON.stringify(mask[key])] as const); 30 | 31 | const source: Observable = (>maskableSubject).pipe( 32 | filter((message): message is U => stringifiedValues.every(([key, value]) => value === JSON.stringify(message[key]))), 33 | map(({ message }) => message) 34 | ); 35 | 36 | super(destination, source); 37 | 38 | this._mask = mask; 39 | this._maskableSubject = maskableSubject; 40 | } 41 | 42 | public close(): void { 43 | this._maskableSubject.close(); 44 | } 45 | 46 | public send(value: T): Promise { 47 | return this._maskableSubject.send({ ...this._mask, message: value }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/classes/web-socket-observer.ts: -------------------------------------------------------------------------------- 1 | import { Observer } from 'rxjs'; 2 | 3 | export class WebSocketObserver implements Observer { 4 | private _webSocket: WebSocket; 5 | 6 | constructor(webSocket: WebSocket) { 7 | this._webSocket = webSocket; 8 | } 9 | 10 | public complete(): void { 11 | // This method does nothing because the DataChannel can be closed separately. 12 | } 13 | 14 | public error(err: Error): void { 15 | throw err; 16 | } 17 | 18 | public next(value: T): void { 19 | this.send(value); 20 | } 21 | 22 | public send(message: T): Promise { 23 | const stringifiedMessage = JSON.stringify(message); 24 | 25 | if (this._webSocket.readyState === WebSocket.OPEN) { 26 | this._webSocket.send(stringifiedMessage); 27 | 28 | return Promise.resolve(); 29 | } 30 | 31 | if (this._webSocket.readyState === WebSocket.CLOSING) { 32 | return Promise.reject(new Error('The WebSocket is already closing.')); 33 | } 34 | 35 | if (this._webSocket.readyState === WebSocket.CLOSED) { 36 | return Promise.reject(new Error('The WebSocket is already closed.')); 37 | } 38 | 39 | return new Promise((resolve, reject) => { 40 | const handleErrorEvent = () => { 41 | this._webSocket.removeEventListener('error', handleErrorEvent); 42 | this._webSocket.removeEventListener('open', handleOpenEvent); // tslint:disable-line:no-use-before-declare 43 | 44 | reject(new Error('Unknown WebSocket Error')); 45 | }; 46 | 47 | const handleOpenEvent = () => { 48 | this._webSocket.removeEventListener('error', handleErrorEvent); 49 | this._webSocket.removeEventListener('open', handleOpenEvent); 50 | 51 | this._webSocket.send(stringifiedMessage); 52 | 53 | resolve(); 54 | }; 55 | 56 | this._webSocket.addEventListener('error', handleErrorEvent); 57 | this._webSocket.addEventListener('open', handleOpenEvent); 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/unit/classes/web-socket-subject.js: -------------------------------------------------------------------------------- 1 | import { WebSocketMock } from '../../mock/web-socket'; 2 | import { WebSocketSubject } from '../../../src/classes/web-socket-subject'; 3 | import { createTransportObservable } from '../../../src/factories/transport-observable'; 4 | import { createWebSocketObserver } from '../../../src/factories/web-socket-observer'; 5 | import { filter } from 'rxjs/operators'; 6 | import { spy } from 'sinon'; 7 | 8 | describe('WebSocketSubject', () => { 9 | let openObserver; 10 | let webSocket; 11 | let webSocketSubject; 12 | 13 | beforeEach(() => { 14 | openObserver = { next: spy() }; 15 | webSocket = new WebSocketMock(); 16 | webSocketSubject = new WebSocketSubject(createTransportObservable, createWebSocketObserver, webSocket, { openObserver }); 17 | }); 18 | 19 | it('should allow to be used with other operators', (done) => { 20 | const message = 'a fake message'; 21 | const webSocketSubscription = webSocketSubject.pipe(filter(() => true)).subscribe({ 22 | next(mssg) { 23 | expect(mssg).to.equal(message); 24 | 25 | webSocketSubscription.unsubscribe(); 26 | 27 | done(); 28 | } 29 | }); 30 | 31 | webSocket.dispatchEvent({ data: message, type: 'message' }); 32 | }); 33 | 34 | describe('close()', () => { 35 | it('should close the socket', () => { 36 | webSocketSubject.close(); 37 | 38 | expect(webSocket.close).to.have.been.calledOnce; 39 | expect(webSocket.close).to.have.been.calledWithExactly(); 40 | }); 41 | }); 42 | 43 | describe('subscribe()', () => { 44 | it('should call next on the given openObserver when the socket is open', () => { 45 | const webSocketSubscription = webSocketSubject.subscribe(); 46 | 47 | webSocket.dispatchEvent({ type: 'open' }); 48 | 49 | expect(openObserver.next).to.have.been.calledOnce; 50 | 51 | webSocketSubscription.unsubscribe(); 52 | }); 53 | 54 | it('should emit a message from the socket', (done) => { 55 | const message = 'a fake message'; 56 | const webSocketSubscription = webSocketSubject.subscribe({ 57 | next(mssg) { 58 | expect(mssg).to.equal(message); 59 | 60 | webSocketSubscription.unsubscribe(); 61 | 62 | done(); 63 | } 64 | }); 65 | 66 | webSocket.dispatchEvent({ data: message, type: 'message' }); 67 | }); 68 | 69 | it('should emit an error from the socket', (done) => { 70 | const error = 'a fake error'; 71 | 72 | webSocketSubject.subscribe({ 73 | error(err) { 74 | expect(err).to.equal(error); 75 | 76 | done(); 77 | } 78 | }); 79 | 80 | webSocket.dispatchEvent({ error, type: 'error' }); 81 | }); 82 | 83 | it('should complete when the socket gets closed', (done) => { 84 | webSocketSubject.subscribe({ 85 | complete() { 86 | done(); 87 | } 88 | }); 89 | 90 | webSocket.dispatchEvent({ type: 'close' }); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /test/unit/classes/masked-subject.js: -------------------------------------------------------------------------------- 1 | import { DataChannelMock } from '../../mock/data-channel'; 2 | import { DataChannelSubject } from '../../../src/classes/data-channel-subject'; 3 | import { MaskedSubject } from '../../../src/classes/masked-subject'; 4 | import { WebSocketMock } from '../../mock/web-socket'; 5 | import { WebSocketSubject } from '../../../src/classes/web-socket-subject'; 6 | import { createDataChannelObserver } from '../../../src/factories/data-channel-observer'; 7 | import { createTransportObservable } from '../../../src/factories/transport-observable'; 8 | import { createWebSocketObserver } from '../../../src/factories/web-socket-observer'; 9 | import { getTypedKeys } from '../../../src/functions/get-typed-keys'; 10 | 11 | describe('MaskedSubject', () => { 12 | for (const transportLayer of ['DataChannel', 'WebSocket']) { 13 | describe(`with a ${transportLayer}Subject`, () => { 14 | let dataChannelOrWebSocket; 15 | let message; 16 | let maskedSubject; 17 | 18 | beforeEach(() => { 19 | dataChannelOrWebSocket = transportLayer === 'DataChannel' ? new DataChannelMock() : new WebSocketMock(); 20 | message = { a: 'fake message' }; 21 | maskedSubject = new MaskedSubject( 22 | getTypedKeys, 23 | { a: { fake: 'mask' } }, 24 | transportLayer === 'DataChannel' 25 | ? new DataChannelSubject(createDataChannelObserver, createTransportObservable, dataChannelOrWebSocket, {}) 26 | : new WebSocketSubject(createTransportObservable, createWebSocketObserver, dataChannelOrWebSocket, {}) 27 | ); 28 | }); 29 | 30 | it('should augment messages with the mask when calling next()', () => { 31 | dataChannelOrWebSocket.readyState = transportLayer === 'DataChannel' ? 'open' : WebSocket.OPEN; 32 | 33 | maskedSubject.next(message); 34 | 35 | expect(dataChannelOrWebSocket.send).to.have.been.calledOnce; 36 | expect(dataChannelOrWebSocket.send).to.have.been.calledWithExactly('{"a":{"fake":"mask"},"message":{"a":"fake message"}}'); 37 | }); 38 | 39 | it('should augment messages with the mask when calling send()', (done) => { 40 | dataChannelOrWebSocket.readyState = transportLayer === 'DataChannel' ? 'open' : WebSocket.OPEN; 41 | 42 | maskedSubject.send(message).then(() => { 43 | expect(dataChannelOrWebSocket.send).to.have.been.calledOnce; 44 | expect(dataChannelOrWebSocket.send).to.have.been.calledWithExactly( 45 | '{"a":{"fake":"mask"},"message":{"a":"fake message"}}' 46 | ); 47 | 48 | done(); 49 | }); 50 | }); 51 | 52 | it('should filter messages by the mask', (done) => { 53 | const subscription = maskedSubject.subscribe({ 54 | next(mssg) { 55 | expect(mssg).to.equal(message); 56 | 57 | subscription.unsubscribe(); 58 | 59 | done(); 60 | } 61 | }); 62 | 63 | dataChannelOrWebSocket.dispatchEvent({ data: { a: { fake: 'mask' }, message }, type: 'message' }); 64 | }); 65 | }); 66 | } 67 | }); 68 | -------------------------------------------------------------------------------- /test/unit/classes/data-channel-subject.js: -------------------------------------------------------------------------------- 1 | import { DataChannelMock } from '../../mock/data-channel'; 2 | import { DataChannelSubject } from '../../../src/classes/data-channel-subject'; 3 | import { createDataChannelObserver } from '../../../src/factories/data-channel-observer'; 4 | import { createTransportObservable } from '../../../src/factories/transport-observable'; 5 | import { filter } from 'rxjs/operators'; 6 | import { spy } from 'sinon'; 7 | 8 | describe('DataChannelSubject', () => { 9 | let dataChannel; 10 | let dataChannelSubject; 11 | let openObserver; 12 | 13 | beforeEach(() => { 14 | openObserver = { next: spy() }; 15 | dataChannel = new DataChannelMock(); 16 | dataChannelSubject = new DataChannelSubject(createDataChannelObserver, createTransportObservable, dataChannel, { openObserver }); 17 | }); 18 | 19 | it('should allow to be used with other operators', (done) => { 20 | const message = 'a fake message'; 21 | const dataChannelSubscription = dataChannelSubject.pipe(filter(() => true)).subscribe({ 22 | next(mssg) { 23 | expect(mssg).to.equal(message); 24 | 25 | dataChannelSubscription.unsubscribe(); 26 | 27 | done(); 28 | } 29 | }); 30 | 31 | dataChannel.dispatchEvent({ data: message, type: 'message' }); 32 | }); 33 | 34 | describe('close()', () => { 35 | it('should close the data channel', () => { 36 | dataChannelSubject.close(); 37 | 38 | expect(dataChannel.close).to.have.been.calledOnce; 39 | expect(dataChannel.close).to.have.been.calledWithExactly(); 40 | }); 41 | }); 42 | 43 | describe('subscribe()', () => { 44 | it('should call next on the given openObserver when the data channel is open', () => { 45 | const dataChannelSubscription = dataChannelSubject.subscribe(); 46 | 47 | dataChannel.dispatchEvent({ type: 'open' }); 48 | 49 | expect(openObserver.next).to.have.been.calledOnce; 50 | 51 | dataChannelSubscription.unsubscribe(); 52 | }); 53 | 54 | it('should emit a message from the data channel', (done) => { 55 | const message = 'a fake message'; 56 | const dataChannelSubscription = dataChannelSubject.subscribe({ 57 | next(mssg) { 58 | expect(mssg).to.equal(message); 59 | 60 | dataChannelSubscription.unsubscribe(); 61 | 62 | done(); 63 | } 64 | }); 65 | 66 | dataChannel.dispatchEvent({ data: message, type: 'message' }); 67 | }); 68 | 69 | it('should emit an error from the data channel', (done) => { 70 | const error = 'a fake error'; 71 | 72 | dataChannelSubject.subscribe({ 73 | error(err) { 74 | expect(err).to.equal(error); 75 | 76 | done(); 77 | } 78 | }); 79 | 80 | dataChannel.dispatchEvent({ error, type: 'error' }); 81 | }); 82 | 83 | it('should complete when the data channel gets closed', (done) => { 84 | dataChannelSubject.subscribe({ 85 | complete() { 86 | done(); 87 | } 88 | }); 89 | 90 | dataChannel.dispatchEvent({ type: 'close' }); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/classes/data-channel-observer.ts: -------------------------------------------------------------------------------- 1 | import { Observer } from 'rxjs'; 2 | 3 | const BUFFERED_AMOUNT_LOW_THRESHOLD = 2048; 4 | 5 | export class DataChannelObserver implements Observer { 6 | private _dataChannel: RTCDataChannel; 7 | 8 | private _isSupportingBufferedAmountLowThreshold: boolean; 9 | 10 | constructor(dataChannel: RTCDataChannel) { 11 | this._dataChannel = dataChannel; 12 | 13 | if (typeof dataChannel.bufferedAmountLowThreshold === 'number') { 14 | this._isSupportingBufferedAmountLowThreshold = true; 15 | 16 | if (dataChannel.bufferedAmountLowThreshold === 0) { 17 | dataChannel.bufferedAmountLowThreshold = BUFFERED_AMOUNT_LOW_THRESHOLD; 18 | } 19 | } else { 20 | this._isSupportingBufferedAmountLowThreshold = false; 21 | } 22 | } 23 | 24 | public complete(): void { 25 | // This method does nothing because the DataChannel can be closed separately. 26 | } 27 | 28 | public error(err: Error): void { 29 | throw err; 30 | } 31 | 32 | public next(value: T): void { 33 | this.send(value); 34 | } 35 | 36 | public send(message: T): Promise { 37 | if (this._dataChannel.readyState === 'open') { 38 | if ( 39 | this._isSupportingBufferedAmountLowThreshold && 40 | this._dataChannel.bufferedAmount > this._dataChannel.bufferedAmountLowThreshold 41 | ) { 42 | return new Promise((resolve, reject) => { 43 | const handleBufferedAmountLowEvent = () => { 44 | this._dataChannel.removeEventListener('bufferedamountlow', handleBufferedAmountLowEvent); 45 | this._dataChannel.removeEventListener('error', handleErrorEvent); // tslint:disable-line:no-use-before-declare 46 | 47 | this.send(message); 48 | 49 | resolve(); 50 | }; 51 | 52 | const handleErrorEvent = (({ error }: ErrorEvent) => { 53 | this._dataChannel.removeEventListener('bufferedamountlow', handleBufferedAmountLowEvent); 54 | this._dataChannel.removeEventListener('error', handleErrorEvent); 55 | 56 | reject(error); 57 | }); 58 | 59 | this._dataChannel.addEventListener('bufferedamountlow', handleBufferedAmountLowEvent); 60 | this._dataChannel.addEventListener('error', handleErrorEvent); 61 | }); 62 | } 63 | 64 | return Promise.resolve(this._dataChannel.send(JSON.stringify(message))); 65 | } 66 | 67 | return new Promise((resolve, reject) => { 68 | const handleErrorEvent = (({ error }: ErrorEvent) => { 69 | this._dataChannel.removeEventListener('error', handleErrorEvent); 70 | this._dataChannel.removeEventListener('open', handleOpenEvent); // tslint:disable-line:no-use-before-declare 71 | 72 | reject(error); 73 | }); 74 | 75 | const handleOpenEvent = () => { 76 | this._dataChannel.removeEventListener('error', handleErrorEvent); 77 | this._dataChannel.removeEventListener('open', handleOpenEvent); 78 | 79 | resolve(this.send(message)); 80 | }; 81 | 82 | this._dataChannel.addEventListener('error', handleErrorEvent); 83 | this._dataChannel.addEventListener('open', handleOpenEvent); 84 | }); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Christoph Guttandin", 3 | "bugs": { 4 | "url": "https://github.com/chrisguttandin/rxjs-broker/issues" 5 | }, 6 | "config": { 7 | "commitizen": { 8 | "path": "cz-conventional-changelog" 9 | } 10 | }, 11 | "dependencies": { 12 | "@babel/runtime": "^7.28.4", 13 | "tslib": "^2.8.1" 14 | }, 15 | "description": "An RxJS message broker for WebRTC DataChannels and WebSockets.", 16 | "devDependencies": { 17 | "@babel/core": "^7.28.5", 18 | "@babel/plugin-external-helpers": "^7.27.1", 19 | "@babel/plugin-transform-runtime": "^7.28.5", 20 | "@babel/preset-env": "^7.28.5", 21 | "@commitlint/cli": "^19.8.1", 22 | "@commitlint/config-angular": "^19.8.1", 23 | "@rollup/plugin-babel": "^6.1.0", 24 | "chai": "^4.3.10", 25 | "commitizen": "^4.3.1", 26 | "cz-conventional-changelog": "^3.3.0", 27 | "eslint": "^8.57.0", 28 | "eslint-config-holy-grail": "^61.0.3", 29 | "husky": "^9.1.7", 30 | "karma": "^6.4.4", 31 | "karma-browserstack-launcher": "^1.6.0", 32 | "karma-chrome-launcher": "^3.2.0", 33 | "karma-firefox-launcher": "^2.1.3", 34 | "karma-mocha": "^2.0.1", 35 | "karma-sinon-chai": "^2.0.2", 36 | "karma-webkit-launcher": "^2.6.0", 37 | "karma-webpack": "^5.0.1", 38 | "lint-staged": "^16.2.7", 39 | "mocha": "^11.7.5", 40 | "prettier": "^3.7.4", 41 | "rimraf": "^6.1.2", 42 | "rollup": "^4.53.5", 43 | "rxjs": "^7.8.2", 44 | "sinon": "^17.0.2", 45 | "sinon-chai": "^3.7.0", 46 | "ts-loader": "^9.5.4", 47 | "tsconfig-holy-grail": "^15.0.2", 48 | "tslint": "^6.1.3", 49 | "tslint-config-holy-grail": "^56.0.6", 50 | "typescript": "^5.9.3", 51 | "webpack": "^5.104.1", 52 | "ws": "^8.18.3" 53 | }, 54 | "files": [ 55 | "build/es2019/", 56 | "build/es5/", 57 | "src/" 58 | ], 59 | "homepage": "https://github.com/chrisguttandin/rxjs-broker", 60 | "license": "MIT", 61 | "main": "build/es5/bundle.js", 62 | "module": "build/es2019/module.js", 63 | "name": "rxjs-broker", 64 | "peerDependencies": { 65 | "rxjs": "^7.3.0" 66 | }, 67 | "repository": { 68 | "type": "git", 69 | "url": "https://github.com/chrisguttandin/rxjs-broker.git" 70 | }, 71 | "scripts": { 72 | "build": "rimraf build/* && tsc --project src/tsconfig.json && rollup --config config/rollup/bundle.mjs", 73 | "lint": "npm run lint:config && npm run lint:src && npm run lint:test", 74 | "lint:config": "eslint --config config/eslint/config.json --ext .cjs --ext .js --ext .mjs --report-unused-disable-directives config/", 75 | "lint:src": "tslint --config config/tslint/src.json --project src/tsconfig.json src/*.ts src/**/*.ts", 76 | "lint:test": "eslint --config config/eslint/test.json --ext .js --report-unused-disable-directives test/", 77 | "prepare": "husky", 78 | "prepublishOnly": "npm run build", 79 | "test": "npm run lint && npm run build && npm run test:integration && npm run test:unit", 80 | "test:integration": "if [ \"$TYPE\" = \"\" -o \"$TYPE\" = \"integration\" ]; then karma start config/karma/config-integration.js --single-run; fi", 81 | "test:unit": "if [ \"$TYPE\" = \"\" -o \"$TYPE\" = \"unit\" ]; then karma start config/karma/config-unit.js --single-run; fi" 82 | }, 83 | "types": "build/es2019/module.d.ts", 84 | "version": "7.1.65" 85 | } 86 | -------------------------------------------------------------------------------- /config/karma/config-unit.js: -------------------------------------------------------------------------------- 1 | const { env } = require('process'); 2 | const { DefinePlugin } = require('webpack'); 3 | 4 | module.exports = (config) => { 5 | config.set({ 6 | basePath: '../../', 7 | 8 | browserDisconnectTimeout: 100000, 9 | 10 | browserNoActivityTimeout: 100000, 11 | 12 | client: { 13 | mocha: { 14 | bail: true, 15 | timeout: 20000 16 | } 17 | }, 18 | 19 | concurrency: 1, 20 | 21 | files: [ 22 | { 23 | included: false, 24 | pattern: 'src/**', 25 | served: false, 26 | watched: true 27 | }, 28 | 'test/unit/**/*.js' 29 | ], 30 | 31 | frameworks: ['mocha', 'sinon-chai'], 32 | 33 | preprocessors: { 34 | 'test/unit/**/*.js': 'webpack' 35 | }, 36 | 37 | reporters: ['dots'], 38 | 39 | webpack: { 40 | mode: 'development', 41 | module: { 42 | rules: [ 43 | { 44 | test: /\.ts?$/, 45 | use: { 46 | loader: 'ts-loader', 47 | options: { 48 | compilerOptions: { 49 | declaration: false, 50 | declarationMap: false 51 | } 52 | } 53 | } 54 | } 55 | ] 56 | }, 57 | plugins: [ 58 | new DefinePlugin({ 59 | 'process.env': { 60 | CI: JSON.stringify(env.CI) 61 | } 62 | }) 63 | ], 64 | resolve: { 65 | extensions: ['.js', '.ts'], 66 | fallback: { util: false } 67 | } 68 | }, 69 | 70 | webpackMiddleware: { 71 | noInfo: true 72 | } 73 | }); 74 | 75 | if (env.CI) { 76 | config.set({ 77 | browserStack: { 78 | accessKey: env.BROWSER_STACK_ACCESS_KEY, 79 | build: `${env.GITHUB_RUN_ID}/unit-${env.TARGET}`, 80 | forceLocal: true, 81 | localIdentifier: `${Math.floor(Math.random() * 1000000)}`, 82 | project: env.GITHUB_REPOSITORY, 83 | username: env.BROWSER_STACK_USERNAME, 84 | video: false 85 | }, 86 | 87 | browsers: 88 | env.TARGET === 'chrome' 89 | ? ['ChromeBrowserStack'] 90 | : env.TARGET === 'firefox' 91 | ? ['FirefoxBrowserStack'] 92 | : env.TARGET === 'safari' 93 | ? ['SafariBrowserStack'] 94 | : ['ChromeBrowserStack', 'FirefoxBrowserStack', 'SafariBrowserStack'], 95 | 96 | captureTimeout: 300000, 97 | 98 | customLaunchers: { 99 | ChromeBrowserStack: { 100 | base: 'BrowserStack', 101 | browser: 'chrome', 102 | captureTimeout: 300, 103 | os: 'OS X', 104 | os_version: 'Ventura' // eslint-disable-line camelcase 105 | }, 106 | FirefoxBrowserStack: { 107 | base: 'BrowserStack', 108 | browser: 'firefox', 109 | captureTimeout: 300, 110 | os: 'Windows', 111 | os_version: '10' // eslint-disable-line camelcase 112 | }, 113 | SafariBrowserStack: { 114 | base: 'BrowserStack', 115 | browser: 'safari', 116 | captureTimeout: 300, 117 | os: 'OS X', 118 | os_version: 'Ventura' // eslint-disable-line camelcase 119 | } 120 | } 121 | }); 122 | } else { 123 | config.set({ 124 | browsers: ['ChromeCanaryHeadless', 'ChromeHeadless', 'FirefoxDeveloperHeadless', 'FirefoxHeadless', 'Safari'] 125 | }); 126 | } 127 | }; 128 | -------------------------------------------------------------------------------- /test/unit/classes/transport-observable.js: -------------------------------------------------------------------------------- 1 | import { DataChannelMock } from '../../mock/data-channel'; 2 | import { TransportObservable } from '../../../src/classes/transport-observable'; 3 | import { WebSocketMock } from '../../mock/web-socket'; 4 | import { spy } from 'sinon'; 5 | 6 | describe('TransportObservable', () => { 7 | let openObserver; 8 | 9 | beforeEach(() => { 10 | openObserver = { next: spy() }; 11 | }); 12 | 13 | for (const transportLayer of ['DataChannel', 'WebSocket']) { 14 | let transport; 15 | let transportObservable; 16 | 17 | beforeEach(() => { 18 | transport = transportLayer === 'DataChannel' ? new DataChannelMock() : new WebSocketMock(); 19 | transportObservable = new TransportObservable(transport, { openObserver }); 20 | }); 21 | 22 | describe('subscribe()', () => { 23 | it('should call next on the given openObserver on an open event', () => { 24 | const transportSubscription = transportObservable.subscribe(); 25 | 26 | transport.dispatchEvent({ type: 'open' }); 27 | 28 | expect(openObserver.next).to.have.been.calledOnce; 29 | 30 | transportSubscription.unsubscribe(); 31 | }); 32 | 33 | it('should remove the open listener when the subscription is canceled', () => { 34 | transportObservable.subscribe().unsubscribe(); 35 | 36 | expect(transport.removeEventListener).to.have.been.called; 37 | expect(transport.removeEventListener).to.have.been.calledWith('open'); 38 | }); 39 | 40 | it('should pass on a message event to the subscribed observer', (done) => { 41 | const message = 'a fake message'; 42 | const transportSubscription = transportObservable.subscribe({ 43 | next(mssg) { 44 | expect(mssg).to.equal(message); 45 | 46 | transportSubscription.unsubscribe(); 47 | 48 | done(); 49 | } 50 | }); 51 | 52 | transport.dispatchEvent({ data: message, type: 'message' }); 53 | }); 54 | 55 | it('should remove the message listener when the subscription is canceled', () => { 56 | transportObservable.subscribe().unsubscribe(); 57 | 58 | expect(transport.removeEventListener).to.have.been.called; 59 | expect(transport.removeEventListener).to.have.been.calledWith('message'); 60 | }); 61 | 62 | it('should pass on an error event to the subscribed observer', (done) => { 63 | const error = 'a fake error'; 64 | 65 | transportObservable.subscribe({ 66 | error(err) { 67 | expect(err).to.equal(error); 68 | 69 | done(); 70 | } 71 | }); 72 | 73 | transport.dispatchEvent({ error, type: 'error' }); 74 | }); 75 | 76 | it('should generate an error and pass it on to the subscribed observer', (done) => { 77 | transportObservable.subscribe({ 78 | error(err) { 79 | expect(err.message).to.equal('Unknown Error'); 80 | 81 | done(); 82 | } 83 | }); 84 | 85 | transport.dispatchEvent({ type: 'error' }); 86 | }); 87 | 88 | it('should remove the error listener when the subscription is canceled', () => { 89 | transportObservable.subscribe().unsubscribe(); 90 | 91 | expect(transport.removeEventListener).to.have.been.called; 92 | expect(transport.removeEventListener).to.have.been.calledWith('error'); 93 | }); 94 | 95 | it('should complete the subscribed observer on a close event', (done) => { 96 | transportObservable.subscribe({ 97 | complete() { 98 | done(); 99 | } 100 | }); 101 | 102 | transport.dispatchEvent({ type: 'close' }); 103 | }); 104 | 105 | it('should remove the close listener when the subscription is canceled', () => { 106 | transportObservable.subscribe().unsubscribe(); 107 | 108 | expect(transport.removeEventListener).to.have.been.called; 109 | expect(transport.removeEventListener).to.have.been.calledWith('close'); 110 | }); 111 | }); 112 | } 113 | }); 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rxjs-broker 2 | 3 | **An RxJS message broker for WebRTC DataChannels and WebSockets.** 4 | 5 | [![version](https://img.shields.io/npm/v/rxjs-broker.svg?style=flat-square)](https://www.npmjs.com/package/rxjs-broker) 6 | 7 | This module is using the power of [RxJS](https://rxjs.dev) to wrap WebSockets or WebRTC DataChannels. It returns a [Subject](https://rxjs.dev/api/index/class/Subject) which can be used with all the operators that RxJS provides. But it also provides some additional functionality. 8 | 9 | ## Usage 10 | 11 | To install `rxjs-broker` via [npm](https://www.npmjs.com/package/rxjs-broker) you can run the following command. 12 | 13 | ```shell 14 | npm install rxjs-broker 15 | ``` 16 | 17 | `rxjs-broker` does provide two utility functions: `connect()` and `wrap()`. If you're using ES2015 modules you can import them like that. 18 | 19 | ```js 20 | import { connect, wrap } from 'rxjs-broker'; 21 | ``` 22 | 23 | ### connect(url: string, subjectConfig?: { openObserver?: NextObserver\ }): WebSocketSubject 24 | 25 | The `connect()` function takes a URL as a parameter and returns a `WebSocketSubject` which extends the `AnonymousSubject` provided by RxJS. It also implements the `IRemoteSubject` interface which adds two additional methods. It gets explained in more detail below. 26 | 27 | ```js 28 | const webSocketSubject = connect('wss://super-cool-websock.et'); 29 | ``` 30 | 31 | The second parameter can be used to specify an `openObserver` which works similar to the [`openObserver` of the `WebSocketSubject` provided by RxJS](https://rxjs-dev.firebaseapp.com/api/webSocket/WebSocketSubjectConfig#openObserver). The `next()` method of it gets called when the underlying WebSocket emits an open event. 32 | 33 | ### wrap(dataChannel: DataChannel, subjectConfig?: { openObserver?: NextObserver\ }): DataChannelSubject 34 | 35 | The `wrap()` function can be used to turn a WebRTC DataChannel into a `DataChannelSubject` which does also extend the `AnonymousSubject` and implements the `IRemoteSubject` interface. 36 | 37 | ```js 38 | // Let's imagine a variable called dataChannel exists and its value is a WebRTC DataChannel. 39 | const dataChannelSubject = wrap(dataChannel); 40 | ``` 41 | 42 | The second parameter can be used to specify an `openObserver`. The `next()` method of it gets called when the underlying DataChannel emits an open event. 43 | 44 | ### IRemoteSubject 45 | 46 | As mentioned above the `IRemoteSubject` interface is used to describe the common behavior of the `DataChannelSubject` and the `WebSocketSubject`. In TypeScript it looks like this: 47 | 48 | ```typescript 49 | interface IRemoteSubject { 50 | close(): void; 51 | 52 | send(message: T): Promise; 53 | } 54 | ``` 55 | 56 | #### close() 57 | 58 | The `close()` method is meant to close the underlying WebSocket or WebRTC DataChannel. 59 | 60 | #### send(message): Promise 61 | 62 | The `send()` method is a supercharged version of `next()`. It will stringify a given JSON message before sending it and returns a `Promise` which resolves when the message is actually on it's way. 63 | 64 | ### mask(mask, maskableSubject): IRemoteSubject 65 | 66 | `rxjs-broker` does also provide another standalone function called `mask()`. It can be imported like that. 67 | 68 | ```js 69 | import { mask } from 'rxjs-broker'; 70 | ``` 71 | 72 | The `mask()` function takes a JSON object which gets used to extract incoming data and to enhance outgoing data. If there is for example a DataChannel which receives two types of messages (control messages and measurement messages), they might look somehow like this: 73 | 74 | ```json 75 | { 76 | "type": "control", 77 | "message": { 78 | "heating": "off" 79 | } 80 | } 81 | ``` 82 | 83 | ```json 84 | { 85 | "type": "measurement", 86 | "message": { 87 | "temperature": "30°" 88 | } 89 | } 90 | ``` 91 | 92 | In case you are not interested in the messages of type control and only want to receive and send messages of type measurement, you can use `mask()` to achieve exactly that. 93 | 94 | ```js 95 | const maskedSubject = mask({ type: 'measurement' }, dataChannelSubject); 96 | 97 | // The callback will be called with unwrapped messages like { temperature: '30°' }. 98 | maskedSubject.subscribe((message) => { 99 | // ... 100 | }); 101 | ``` 102 | 103 | When you call `next()` or `send()` on the returned `IRemoteSubject` it also wraps the message with the provided mask. Considering the example introduced above, the usage of the `send()` method will look like this: 104 | 105 | ```js 106 | const maskedSubject = mask({ type: 'measurement' }, dataChannelSubject); 107 | 108 | // This will send wrapped messages like { type: 'measurement', message: { temperature: '30°' } }. 109 | maskedSubject.send({ temperature: '30°' }); 110 | ``` 111 | -------------------------------------------------------------------------------- /config/karma/config-integration.js: -------------------------------------------------------------------------------- 1 | const { env } = require('process'); 2 | const { DefinePlugin } = require('webpack'); 3 | const { Server } = require('ws'); 4 | 5 | module.exports = (config) => { 6 | config.set({ 7 | basePath: '../../', 8 | 9 | browserDisconnectTimeout: 100000, 10 | 11 | browserNoActivityTimeout: 100000, 12 | 13 | client: { 14 | mocha: { 15 | bail: true, 16 | timeout: 20000 17 | } 18 | }, 19 | 20 | concurrency: 1, 21 | 22 | files: [ 23 | { 24 | included: false, 25 | pattern: 'src/**', 26 | served: false, 27 | watched: true 28 | }, 29 | 'test/integration/**/*.js' 30 | ], 31 | 32 | frameworks: ['mocha', 'sinon-chai'], 33 | 34 | plugins: [ 35 | { 36 | 'reporter:web-socket': [ 37 | 'type', 38 | function () { 39 | let server; 40 | 41 | this.onRunComplete = () => { 42 | server.close(); 43 | }; 44 | 45 | this.onRunStart = () => { 46 | server = new Server({ port: 5432 }); 47 | 48 | server.on('connection', (ws) => ws.on('message', (message) => ws.send(message.toString()))); 49 | }; 50 | } 51 | ] 52 | }, 53 | 'karma-*' 54 | ], 55 | 56 | preprocessors: { 57 | 'test/integration/**/*.js': 'webpack' 58 | }, 59 | 60 | reporters: ['dots', 'web-socket'], 61 | 62 | webpack: { 63 | mode: 'development', 64 | module: { 65 | rules: [ 66 | { 67 | test: /\.ts?$/, 68 | use: { 69 | loader: 'ts-loader', 70 | options: { 71 | compilerOptions: { 72 | declaration: false, 73 | declarationMap: false 74 | } 75 | } 76 | } 77 | } 78 | ] 79 | }, 80 | plugins: [ 81 | new DefinePlugin({ 82 | 'process.env': { 83 | CI: JSON.stringify(env.CI) 84 | } 85 | }) 86 | ], 87 | resolve: { 88 | extensions: ['.js', '.ts'], 89 | fallback: { util: false } 90 | } 91 | }, 92 | 93 | webpackMiddleware: { 94 | noInfo: true 95 | } 96 | }); 97 | 98 | if (env.CI) { 99 | config.set({ 100 | browserStack: { 101 | accessKey: env.BROWSER_STACK_ACCESS_KEY, 102 | build: `${env.GITHUB_RUN_ID}/integration-${env.TARGET}`, 103 | forceLocal: true, 104 | localIdentifier: `${Math.floor(Math.random() * 1000000)}`, 105 | project: env.GITHUB_REPOSITORY, 106 | username: env.BROWSER_STACK_USERNAME, 107 | video: false 108 | }, 109 | 110 | browsers: 111 | env.TARGET === 'chrome' 112 | ? ['ChromeBrowserStack'] 113 | : env.TARGET === 'firefox' 114 | ? ['FirefoxBrowserStack'] 115 | : env.TARGET === 'safari' 116 | ? ['SafariBrowserStack'] 117 | : ['ChromeBrowserStack', 'FirefoxBrowserStack', 'SafariBrowserStack'], 118 | 119 | captureTimeout: 300000, 120 | 121 | customLaunchers: { 122 | ChromeBrowserStack: { 123 | base: 'BrowserStack', 124 | browser: 'chrome', 125 | captureTimeout: 300, 126 | os: 'OS X', 127 | os_version: 'Ventura' // eslint-disable-line camelcase 128 | }, 129 | FirefoxBrowserStack: { 130 | base: 'BrowserStack', 131 | browser: 'firefox', 132 | captureTimeout: 300, 133 | os: 'Windows', 134 | os_version: '10' // eslint-disable-line camelcase 135 | }, 136 | SafariBrowserStack: { 137 | base: 'BrowserStack', 138 | browser: 'safari', 139 | captureTimeout: 300, 140 | os: 'OS X', 141 | os_version: 'Ventura' // eslint-disable-line camelcase 142 | } 143 | } 144 | }); 145 | } else { 146 | config.set({ 147 | browsers: ['ChromeCanaryHeadless', 'ChromeHeadless', 'FirefoxDeveloperHeadless', 'FirefoxHeadless', 'Safari'] 148 | }); 149 | } 150 | }; 151 | -------------------------------------------------------------------------------- /test/unit/classes/data-channel-observer.js: -------------------------------------------------------------------------------- 1 | import { DataChannelMock } from '../../mock/data-channel'; 2 | import { DataChannelObserver } from '../../../src/classes/data-channel-observer'; 3 | 4 | describe('DataChannelObserver', () => { 5 | let dataChannel; 6 | let dataChannelObserver; 7 | 8 | beforeEach(() => { 9 | dataChannel = new DataChannelMock(); 10 | dataChannelObserver = new DataChannelObserver(dataChannel); 11 | }); 12 | 13 | describe('next()', () => { 14 | let value; 15 | 16 | beforeEach(() => (value = 'a fake value')); 17 | 18 | describe('with a connecting data channel', () => { 19 | beforeEach(() => (dataChannel.readyState = 'connecting')); 20 | 21 | it("should wait with sending a given value as message to the data channel until it's open", () => { 22 | dataChannelObserver.next(value); 23 | 24 | expect(dataChannel.send).to.have.not.been.called; 25 | 26 | dataChannel.readyState = 'open'; 27 | dataChannel.dispatchEvent({ type: 'open' }); 28 | 29 | expect(dataChannel.send).to.have.been.calledOnce; 30 | expect(dataChannel.send).to.have.been.calledWithExactly(`"${value}"`); 31 | }); 32 | }); 33 | 34 | describe('with an open data channel', () => { 35 | beforeEach(() => (dataChannel.readyState = 'open')); 36 | 37 | it('should send a given value as message to the data channel', () => { 38 | dataChannelObserver.send(value); 39 | 40 | expect(dataChannel.send).to.have.been.calledOnce; 41 | expect(dataChannel.send).to.have.been.calledWithExactly(`"${value}"`); 42 | }); 43 | }); 44 | 45 | describe('with data channel which supports "bufferedamountlow" events', () => { 46 | beforeEach(() => { 47 | dataChannel.bufferedAmount = 2049; 48 | dataChannel.bufferedAmountLowThreshold = 2048; 49 | dataChannel.readyState = 'open'; 50 | }); 51 | 52 | it('should wait with sending a given value as message to the data channel until its buffered amount of data is below the threshold', () => { 53 | dataChannelObserver.send(value); 54 | 55 | expect(dataChannel.send).to.have.not.been.called; 56 | 57 | dataChannel.bufferedAmount = 2047; 58 | dataChannel.dispatchEvent({ type: 'bufferedamountlow' }); 59 | 60 | expect(dataChannel.send).to.have.been.calledOnce; 61 | expect(dataChannel.send).to.have.been.calledWithExactly(`"${value}"`); 62 | }); 63 | }); 64 | }); 65 | 66 | describe('send()', () => { 67 | let message; 68 | 69 | beforeEach(() => (message = 'a fake message')); 70 | 71 | describe('with a connecting data channel', () => { 72 | beforeEach(() => (dataChannel.readyState = 'connecting')); 73 | 74 | it("should wait with sending a given message to the data channel until it's open", (done) => { 75 | dataChannelObserver.send(message).then(() => { 76 | expect(dataChannel.send).to.have.been.calledOnce; 77 | expect(dataChannel.send).to.have.been.calledWithExactly(`"${message}"`); 78 | 79 | done(); 80 | }); 81 | 82 | expect(dataChannel.send).to.have.not.been.called; 83 | 84 | dataChannel.readyState = 'open'; 85 | dataChannel.dispatchEvent({ type: 'open' }); 86 | }); 87 | }); 88 | 89 | describe('with an open data channel', () => { 90 | beforeEach(() => (dataChannel.readyState = 'open')); 91 | 92 | it('should send a given message to the data channel', (done) => { 93 | dataChannelObserver.send(message).then(() => { 94 | expect(dataChannel.send).to.have.been.calledOnce; 95 | expect(dataChannel.send).to.have.been.calledWithExactly(`"${message}"`); 96 | 97 | done(); 98 | }); 99 | }); 100 | }); 101 | 102 | describe('with data channel which supports "bufferedamountlow" events', () => { 103 | beforeEach(() => { 104 | dataChannel.bufferedAmount = 2049; 105 | dataChannel.bufferedAmountLowThreshold = 2048; 106 | dataChannel.readyState = 'open'; 107 | }); 108 | 109 | it('should wait with sending a given message to the data channel until its buffered amount of data is below the threshold', (done) => { 110 | dataChannelObserver.send(message).then(() => { 111 | expect(dataChannel.send).to.have.been.calledOnce; 112 | expect(dataChannel.send).to.have.been.calledWithExactly(`"${message}"`); 113 | 114 | done(); 115 | }); 116 | 117 | expect(dataChannel.send).to.have.not.been.called; 118 | 119 | dataChannel.bufferedAmount = 2047; 120 | dataChannel.dispatchEvent({ type: 'bufferedamountlow' }); 121 | }); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /test/unit/classes/web-socket-observer.js: -------------------------------------------------------------------------------- 1 | import { WebSocketMock } from '../../mock/web-socket'; 2 | import { WebSocketObserver } from '../../../src/classes/web-socket-observer'; 3 | 4 | describe('WebSocketObserver', () => { 5 | let webSocket; 6 | let webSocketObserver; 7 | 8 | beforeEach(() => { 9 | webSocket = new WebSocketMock(); 10 | webSocketObserver = new WebSocketObserver(webSocket); 11 | }); 12 | 13 | describe('next()', () => { 14 | let value; 15 | 16 | beforeEach(() => (value = 'a fake value')); 17 | 18 | describe('with a connecting socket', () => { 19 | beforeEach(() => (webSocket.readyState = WebSocket.CONNECTING)); 20 | 21 | it("should wait with sending a given value as message to the socket until it's open", () => { 22 | webSocketObserver.next(value); 23 | 24 | expect(webSocket.send).to.have.not.been.called; 25 | 26 | webSocket.readyState = WebSocket.OPEN; 27 | webSocket.dispatchEvent({ type: 'open' }); 28 | 29 | expect(webSocket.send).to.have.been.calledOnce; 30 | expect(webSocket.send).to.have.been.calledWithExactly(`"${value}"`); 31 | }); 32 | 33 | it('should give up sending a given value as message to the socket if there is an error', () => { 34 | webSocketObserver.next(value); 35 | 36 | expect(webSocket.send).to.have.not.been.called; 37 | 38 | webSocket.dispatchEvent({ type: 'error' }); 39 | 40 | expect(webSocket.send).to.have.not.been.called; 41 | }); 42 | }); 43 | 44 | describe('with an open socket', () => { 45 | beforeEach(() => (webSocket.readyState = WebSocket.OPEN)); 46 | 47 | it('should send a given value as message to the socket', () => { 48 | webSocketObserver.next(value); 49 | 50 | expect(webSocket.send).to.have.been.calledOnce; 51 | expect(webSocket.send).to.have.been.calledWithExactly(`"${value}"`); 52 | }); 53 | }); 54 | 55 | describe('with an closing socket', () => { 56 | beforeEach(() => (webSocket.readyState = WebSocket.CLOSING)); 57 | 58 | it('should not send the given message to the socket', () => { 59 | webSocketObserver.next(value); 60 | 61 | expect(webSocket.send).to.have.not.been.called; 62 | }); 63 | }); 64 | 65 | describe('with an closed socket', () => { 66 | beforeEach(() => (webSocket.readyState = WebSocket.CLOSED)); 67 | 68 | it('should not send the given message to the socket', () => { 69 | webSocketObserver.next(value); 70 | 71 | expect(webSocket.send).to.have.not.been.called; 72 | }); 73 | }); 74 | }); 75 | 76 | describe('send()', () => { 77 | let message; 78 | 79 | beforeEach(() => (message = 'a fake message')); 80 | 81 | describe('with a connecting socket', () => { 82 | beforeEach(() => (webSocket.readyState = WebSocket.CONNECTING)); 83 | 84 | it("should wait with sending a given message to the socket until it's open", (done) => { 85 | webSocketObserver.send(message).then(() => { 86 | expect(webSocket.send).to.have.been.calledOnce; 87 | expect(webSocket.send).to.have.been.calledWithExactly(`"${message}"`); 88 | 89 | done(); 90 | }); 91 | 92 | expect(webSocket.send).to.have.not.been.called; 93 | 94 | webSocket.readyState = WebSocket.OPEN; 95 | webSocket.dispatchEvent({ type: 'open' }); 96 | }); 97 | 98 | it('should give up sending a given message to the socket if there is an error', (done) => { 99 | webSocketObserver.send(message).catch((err) => { 100 | expect(err.message).to.equal('Unknown WebSocket Error'); 101 | 102 | expect(webSocket.send).to.have.not.been.called; 103 | 104 | done(); 105 | }); 106 | 107 | expect(webSocket.send).to.have.not.been.called; 108 | 109 | webSocket.dispatchEvent({ type: 'error' }); 110 | }); 111 | }); 112 | 113 | describe('with an open socket', () => { 114 | beforeEach(() => (webSocket.readyState = WebSocket.OPEN)); 115 | 116 | it('should send a given message to the socket', (done) => { 117 | webSocketObserver.send(message).then(() => { 118 | expect(webSocket.send).to.have.been.calledOnce; 119 | expect(webSocket.send).to.have.been.calledWithExactly(`"${message}"`); 120 | 121 | done(); 122 | }); 123 | }); 124 | }); 125 | 126 | describe('with an closing socket', () => { 127 | beforeEach(() => (webSocket.readyState = WebSocket.CLOSING)); 128 | 129 | it('should not send the given message to the socket', (done) => { 130 | webSocketObserver.send(message).catch((err) => { 131 | expect(err.message).to.equal('The WebSocket is already closing.'); 132 | 133 | expect(webSocket.send).to.have.not.been.called; 134 | 135 | done(); 136 | }); 137 | }); 138 | }); 139 | 140 | describe('with an closed socket', () => { 141 | beforeEach(() => (webSocket.readyState = WebSocket.CLOSED)); 142 | 143 | it('should not send the given message to the socket', (done) => { 144 | webSocketObserver.send(message).catch((err) => { 145 | expect(err.message).to.equal('The WebSocket is already closed.'); 146 | 147 | expect(webSocket.send).to.have.not.been.called; 148 | 149 | done(); 150 | }); 151 | }); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/integration/module.js: -------------------------------------------------------------------------------- 1 | import { connect, isSupported, mask, wrap } from '../../src/module'; 2 | import { establishDataChannels } from '../helper/establish-data-channels'; 3 | 4 | describe('module', () => { 5 | describe('isSupported', () => { 6 | it('should be a boolean', () => { 7 | expect(isSupported).to.be.a('boolean'); 8 | }); 9 | }); 10 | 11 | describe('connect()', () => { 12 | let message; 13 | let openObserverNext; 14 | let webSocketSubject; 15 | 16 | afterEach(() => { 17 | webSocketSubject.close(); 18 | }); 19 | 20 | beforeEach(() => { 21 | message = { a: 'b', c: 'd' }; 22 | 23 | webSocketSubject = connect('ws://localhost:5432', { 24 | openObserver: { 25 | next() { 26 | if (openObserverNext !== undefined) { 27 | openObserverNext(); 28 | } 29 | } 30 | } 31 | }); 32 | }); 33 | 34 | it('should call next on a given openObserver', function (done) { 35 | this.timeout(10000); 36 | 37 | openObserverNext = done; 38 | 39 | webSocketSubject.subscribe(); 40 | }); 41 | 42 | it('should connect to a WebSocket and send and receive an unmasked messagge', function (done) { 43 | this.timeout(10000); 44 | 45 | webSocketSubject.subscribe({ 46 | next(mssge) { 47 | expect(mssge).to.deep.equal(message); 48 | 49 | done(); 50 | } 51 | }); 52 | 53 | webSocketSubject.send(message); 54 | }); 55 | 56 | it('should connect to a WebSocket and send and receive a masked messagge', function (done) { 57 | this.timeout(10000); 58 | 59 | webSocketSubject = mask({ a: 'fake mask' }, webSocketSubject); 60 | 61 | webSocketSubject.subscribe({ 62 | next(mssge) { 63 | expect(mssge).to.deep.equal(message); 64 | 65 | done(); 66 | } 67 | }); 68 | 69 | webSocketSubject.send(message); 70 | }); 71 | 72 | it('should connect to a WebSocket and send and receive a deeply masked messagge', function (done) { 73 | this.timeout(10000); 74 | 75 | webSocketSubject = mask({ another: 'fake mask' }, mask({ a: 'fake mask' }, webSocketSubject)); 76 | 77 | webSocketSubject.subscribe({ 78 | next(mssge) { 79 | expect(mssge).to.deep.equal(message); 80 | 81 | done(); 82 | } 83 | }); 84 | 85 | webSocketSubject.send(message); 86 | }); 87 | }); 88 | 89 | describe('mask()', () => { 90 | // @todo 91 | }); 92 | 93 | describe('wrap()', () => { 94 | let dataChannelSubject; 95 | let openObserverNext; 96 | let remoteDataChannel; 97 | 98 | afterEach(() => dataChannelSubject.close()); 99 | 100 | beforeEach(() => { 101 | const dataChannels = establishDataChannels(); 102 | 103 | dataChannelSubject = wrap(dataChannels.localDataChannel, { 104 | openObserver: { 105 | next() { 106 | if (openObserverNext !== undefined) { 107 | openObserverNext(); 108 | } 109 | } 110 | } 111 | }); 112 | remoteDataChannel = dataChannels.remoteDataChannel; 113 | }); 114 | 115 | it('should call next on a given openObserver', function (done) { 116 | this.timeout(10000); 117 | 118 | openObserverNext = done; 119 | 120 | dataChannelSubject.subscribe(); 121 | }); 122 | 123 | describe('with a message', () => { 124 | let message; 125 | 126 | beforeEach(() => { 127 | message = { a: 'b', c: 'd' }; 128 | }); 129 | 130 | it('should send and receive a messagge through an unmasked data channel', function (done) { 131 | this.timeout(10000); 132 | 133 | dataChannelSubject.subscribe({ 134 | next(mssge) { 135 | expect(mssge).to.deep.equal(message); 136 | 137 | done(); 138 | } 139 | }); 140 | 141 | remoteDataChannel.addEventListener('message', (event) => { 142 | expect(event.data).to.equal('{"a":"b","c":"d"}'); 143 | 144 | remoteDataChannel.send(event.data); 145 | }); 146 | 147 | dataChannelSubject.send(message); 148 | }); 149 | 150 | it('should send and receive a messagge through a masked data channel', function (done) { 151 | this.timeout(10000); 152 | 153 | dataChannelSubject = mask({ a: 'fake mask' }, dataChannelSubject); 154 | 155 | dataChannelSubject.subscribe({ 156 | next(mssge) { 157 | expect(mssge).to.deep.equal(message); 158 | 159 | done(); 160 | } 161 | }); 162 | 163 | remoteDataChannel.addEventListener('message', (event) => { 164 | expect(event.data).to.equal('{"a":"fake mask","message":{"a":"b","c":"d"}}'); 165 | 166 | remoteDataChannel.send(event.data); 167 | }); 168 | 169 | dataChannelSubject.send(message); 170 | }); 171 | 172 | it('should send and receive a messagge through a deeply masked data channel', function (done) { 173 | this.timeout(10000); 174 | 175 | dataChannelSubject = mask({ another: 'fake mask' }, mask({ a: 'fake mask' }, dataChannelSubject)); 176 | 177 | dataChannelSubject.subscribe({ 178 | next(mssge) { 179 | expect(mssge).to.deep.equal(message); 180 | 181 | done(); 182 | } 183 | }); 184 | 185 | remoteDataChannel.addEventListener('message', (event) => { 186 | expect(event.data).to.equal('{"a":"fake mask","message":{"another":"fake mask","message":{"a":"b","c":"d"}}}'); 187 | 188 | remoteDataChannel.send(event.data); 189 | }); 190 | 191 | dataChannelSubject.send(message); 192 | }); 193 | }); 194 | 195 | describe('without a message', () => { 196 | it('should send and receive an empty messagge through a masked data channel', function (done) { 197 | this.timeout(10000); 198 | 199 | dataChannelSubject = mask({ a: 'fake mask' }, dataChannelSubject); 200 | 201 | dataChannelSubject.subscribe({ 202 | next(message) { 203 | expect(message).to.be.undefined; 204 | 205 | done(); 206 | } 207 | }); 208 | 209 | remoteDataChannel.addEventListener('message', (event) => { 210 | expect(event.data).to.equal('{"a":"fake mask"}'); 211 | 212 | remoteDataChannel.send(event.data); 213 | }); 214 | 215 | dataChannelSubject.send(); 216 | }); 217 | }); 218 | }); 219 | }); 220 | --------------------------------------------------------------------------------