├── .npmrc ├── schematics ├── src │ ├── index.spec.ts │ ├── schema.ts │ ├── files │ │ ├── ts-config │ │ │ └── tsconfig.worker.json.template │ │ └── worker │ │ │ ├── __name@dasherize__.worker.ts.template │ │ │ └── __name@dasherize__.worker.spec.ts.template │ ├── worker-ts-config.ts │ ├── schema.json │ └── index.ts ├── collection.json ├── package.json └── tsconfig.schematics.json ├── testing ├── src │ ├── public-api.ts │ └── lib │ │ ├── worker-testing-module.ts │ │ ├── worker-testing-client.ts │ │ └── worker-testing-manager.ts ├── package.json └── test │ ├── worker-testing-manager.spec.ts │ ├── worker-testing-client.spec.ts │ └── worker-testing-module.spec.ts ├── angular ├── package.json ├── src │ ├── public-api.ts │ └── lib │ │ ├── worker.module.ts │ │ ├── client-web-worker.ts │ │ ├── worker-manager.ts │ │ ├── worker-client-types.ts │ │ └── worker-client.ts └── test │ ├── worker-manager.spec.ts │ ├── worker-module.spec.ts │ └── client-web-worker.spec.ts ├── common ├── package.json ├── src │ ├── public-api.ts │ └── lib │ │ ├── message-bus.ts │ │ ├── worker-types.ts │ │ ├── worker-utils.ts │ │ ├── annotations.ts │ │ └── worker-events.ts └── test │ └── worker-utils.spec.ts ├── ng-package.json ├── worker ├── src │ ├── lib │ │ ├── lifecycle-hooks.ts │ │ ├── bootstrap-worker.ts │ │ ├── shallow-transfer-decorator.ts │ │ ├── subscribable-decorator.ts │ │ ├── accessable-decorator.ts │ │ ├── callable-decorator.ts │ │ ├── web-worker-decorator.ts │ │ └── worker-controller.ts │ └── public-api.ts └── test │ ├── subscribable-decorator.spec.ts │ ├── shallow-transfer-decorator.spec.ts │ ├── accessable-decorator.spec.ts │ ├── callable-decorator.spec.ts │ ├── web-worker-decorator.spec.ts │ └── worker-controller.spec.ts ├── tsconfig.spec.json ├── package-script.ts ├── tests.ts ├── .gitignore ├── tsconfig.json ├── LICENSE ├── tsconfig.lib.json ├── karma.conf.js ├── package.json ├── tslint.json └── readme.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /schematics/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /schematics/src/schema.ts: -------------------------------------------------------------------------------- 1 | export interface WebWorkerSchema { 2 | name: string; 3 | project: string; 4 | target: string; 5 | path: string; 6 | } -------------------------------------------------------------------------------- /testing/src/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/worker-testing-client'; 2 | export { createTestManager } from './lib/worker-testing-manager'; 3 | export * from './lib/worker-testing-module'; 4 | -------------------------------------------------------------------------------- /angular/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./../node_modules/ng-packagr/package.schema.json", 3 | "ngPackage": { 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /testing/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./../node_modules/ng-packagr/package.schema.json", 3 | "ngPackage": { 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "$schema": "./../node_modules/ng-packagr/package.schema.json", 4 | "ngPackage": { 5 | "lib": { 6 | "entryFile": "src/public-api.ts" 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /common/src/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/annotations'; 2 | export * from './lib/worker-utils'; 3 | export * from './lib/worker-types'; 4 | export * from './lib/worker-events'; 5 | export * from './lib/message-bus'; 6 | 7 | -------------------------------------------------------------------------------- /ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "./dist", 4 | "deleteDestPath": true, 5 | "lib": { 6 | "entryFile": "worker/src/public-api.ts" 7 | } 8 | } -------------------------------------------------------------------------------- /worker/src/lib/lifecycle-hooks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Lifecycle hook that is called after the worker class is connected to from a client 3 | */ 4 | export interface OnWorkerInit { 5 | onWorkerInit: () => void | Promise; 6 | } 7 | -------------------------------------------------------------------------------- /angular/src/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/worker-client'; 2 | export * from './lib/worker-manager'; 3 | export * from './lib/worker.module'; 4 | export * from './lib/worker-client-types'; 5 | export * from './lib/client-web-worker'; 6 | -------------------------------------------------------------------------------- /schematics/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "schematics": { 3 | "angular-web-worker": { 4 | "description": "A blank schematic.", 5 | "schema": "./src/schema.json", 6 | "factory": "./src/index" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "test/out-tsc", 5 | "module": "commonjs", 6 | "types": [ 7 | "jasmine", 8 | "node" 9 | ], 10 | }, 11 | 12 | } -------------------------------------------------------------------------------- /package-script.ts: -------------------------------------------------------------------------------- 1 | import * as ngPackage from 'ng-packagr'; 2 | 3 | ngPackage 4 | .ngPackagr() 5 | .forProject('ng-package.json') 6 | .withTsConfig('tsconfig.lib.json') 7 | .build() 8 | .catch(error => { 9 | console.error(error); 10 | process.exit(1); 11 | }); -------------------------------------------------------------------------------- /schematics/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-web-worker", 3 | "version": "0.0.0", 4 | "description": "A blank schematics", 5 | "scripts": { 6 | "test": "npm run build && jasmine src/**/*_spec.js" 7 | }, 8 | "keywords": [ 9 | "schematics" 10 | ], 11 | "author": "", 12 | "license": "MIT", 13 | "schematics": "./src/collection.json" 14 | } 15 | -------------------------------------------------------------------------------- /schematics/src/files/ts-config/tsconfig.worker.json.template: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "<%= relativePathToWorkspaceRoot %>/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "<%= relativePathToWorkspaceRoot %>/out-tsc/worker", 5 | "lib": [ 6 | "es2018", 7 | "webworker" 8 | ], 9 | "types": [] 10 | }, 11 | "include": [ 12 | "src/**/*.worker.ts" 13 | ] 14 | } -------------------------------------------------------------------------------- /tests.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js/dist/zone'; 2 | import 'zone.js/dist/zone-testing'; 3 | import { getTestBed } from '@angular/core/testing'; 4 | import { 5 | BrowserDynamicTestingModule, 6 | platformBrowserDynamicTesting 7 | } from '@angular/platform-browser-dynamic/testing'; 8 | 9 | getTestBed().initTestEnvironment( 10 | BrowserDynamicTestingModule, 11 | platformBrowserDynamicTesting() 12 | ); 13 | -------------------------------------------------------------------------------- /schematics/src/files/worker/__name@dasherize__.worker.ts.template: -------------------------------------------------------------------------------- 1 | import { AngularWebWorker, bootstrapWorker, OnWorkerInit } from 'angular-web-worker'; 2 | /// 3 | 4 | @AngularWebWorker() 5 | export class <%= classify(name) %>Worker implements OnWorkerInit { 6 | 7 | constructor() {} 8 | 9 | onWorkerInit() { 10 | 11 | } 12 | 13 | } 14 | bootstrapWorker(<%= classify(name) %>Worker); 15 | -------------------------------------------------------------------------------- /worker/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of ng-worker 3 | */ 4 | 5 | export * from './lib/web-worker-decorator'; 6 | export * from './lib/bootstrap-worker'; 7 | export * from './lib/callable-decorator'; 8 | export * from './lib/accessable-decorator'; 9 | export * from './lib/subscribable-decorator'; 10 | export * from './lib/shallow-transfer-decorator'; 11 | export * from './lib/lifecycle-hooks'; 12 | export * from './lib/worker-controller'; 13 | 14 | 15 | -------------------------------------------------------------------------------- /schematics/src/worker-ts-config.ts: -------------------------------------------------------------------------------- 1 | import { WebWorkerSchema } from "./schema"; 2 | import { SchematicContext, Tree, apply, mergeWith, applyTemplates, url, move, Rule, SchematicsException } from "@angular-devkit/schematics"; 3 | import { relativePathToWorkspaceRoot } from "@schematics/angular/utility/paths"; 4 | import { dirname, normalize } from "path"; 5 | import { findPropertyInAstObject } from "@schematics/angular/utility/json-utils"; 6 | import { JsonParseMode, parseJsonAst } from "@angular-devkit/core"; 7 | -------------------------------------------------------------------------------- /common/src/lib/message-bus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interface for message bus provided into a `WorkerController` allowing the communication mechanism to be interchanged between in-app, and native worker 3 | * communication mechansims 4 | */ 5 | export interface WorkerMessageBus { 6 | /** 7 | * Messages transfered from a client to a controller 8 | */ 9 | postMessage: (resp: any) => void; 10 | /** 11 | * Messages recieved by a controller from a client 12 | */ 13 | onmessage: (ev: MessageEvent) => void; 14 | } 15 | -------------------------------------------------------------------------------- /schematics/tsconfig.schematics.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "tsconfig", 4 | "lib": [ 5 | "es2018", 6 | "dom" 7 | ], 8 | "declaration": true, 9 | "module": "commonjs", 10 | "outDir": "./../dist/schematics", 11 | "moduleResolution": "node", 12 | "noEmitOnError": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "rootDir": "./", 15 | "skipDefaultLibCheck": true, 16 | "skipLibCheck": true, 17 | "strictNullChecks": true, 18 | "target": "es6", 19 | "types": [ 20 | "jasmine", 21 | "node" 22 | ] 23 | }, 24 | "include": [ 25 | "src/**/*" 26 | ], 27 | "exclude": [ 28 | "./src/files/**/*", 29 | "./src/**/**.spec.ts" 30 | ] 31 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # profiling files 12 | chrome-profiler-events.json 13 | speed-measure-plugin.json 14 | 15 | # IDEs and editors 16 | /.idea 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # IDE - VSCode 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | .history/* 31 | 32 | .vscode 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | -------------------------------------------------------------------------------- /worker/src/lib/bootstrap-worker.ts: -------------------------------------------------------------------------------- 1 | 2 | import { WorkerController } from './worker-controller'; 3 | import { WebWorkerType, WorkerMessageBus } from 'angular-web-worker/common'; 4 | 5 | /** 6 | * Bootstraps the worker class when a new worker script is created in the browser. The class must be decorated with `@AngularWebWorker()` 7 | * @param worker worker class to bootstrap 8 | */ 9 | export function bootstrapWorker(worker: WebWorkerType) { 10 | 11 | const messageBus: WorkerMessageBus = { 12 | onmessage: (ev: MessageEvent) => { 13 | }, 14 | postMessage: (msg: Response) => { 15 | (postMessage as Function)(msg); 16 | } 17 | }; 18 | const workerController = new WorkerController(worker, messageBus); 19 | 20 | onmessage = (ev: MessageEvent) => { 21 | messageBus.onmessage(ev); 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "downlevelIteration": true, 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "paths": { 18 | "angular-web-worker": [ 19 | "./worker/src/public-api.ts" 20 | ], 21 | "angular-web-worker/angular": [ 22 | "./angular/src/public-api.ts" 23 | ], 24 | "angular-web-worker/common": [ 25 | "./common/src/public-api.ts" 26 | ], 27 | "angular-web-worker/*": [ 28 | "./dist/*" 29 | ] 30 | } 31 | }, 32 | "exclude": [ 33 | "node_modules", "dist" 34 | ] 35 | } -------------------------------------------------------------------------------- /worker/src/lib/shallow-transfer-decorator.ts: -------------------------------------------------------------------------------- 1 | import { WorkerUtils, ShallowTransferParamMetaData, WorkerAnnotations } from 'angular-web-worker/common'; 2 | 3 | /** 4 | * Transfers the decorated argument's prototype when it is serialized and unserialized when the method is called from `WorkerClient.call()`. This will only have an effect if 5 | * the method is decorated with `@Callable()` 6 | * @Experimental has limitations 7 | */ 8 | export function ShallowTransfer() { 9 | return function (target: Object, propertyKey: string | symbol, parameterIndex: number) { 10 | const argTypes: any[] = Reflect.getMetadata('design:paramtypes', target, propertyKey); 11 | WorkerUtils.pushAnnotation(target.constructor, WorkerAnnotations.ShallowTransferArgs, { 12 | name: propertyKey, 13 | type: argTypes[parameterIndex], 14 | argIndex: parameterIndex 15 | }); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /schematics/src/files/worker/__name@dasherize__.worker.spec.ts.template: -------------------------------------------------------------------------------- 1 | import { createTestClient, WorkerTestingClient } from 'angular-web-worker/testing'; 2 | import { <%= classify(name) %>Worker } from './<%= dasherize(name) %>.worker'; 3 | 4 | describe('<%= classify(name) %>Worker', () => { 5 | 6 | // decorated methods/properties should always be tested through the WorkerTestingClient to mock the serialization behaviour 7 | let client: WorkerTestingClient<<%= classify(name) %>Worker>; 8 | 9 | beforeEach(() => { 10 | client = createTestClient(<%= classify(name) %>Worker); 11 | }); 12 | 13 | it('Some test', async () => { 14 | 15 | // this will call the onWorkerInit hook and allow the decorated methods/properties to be called/accessed through the client 16 | await client.connect(); 17 | 18 | // get access to the underlying worker class 19 | // client.workerInstance 20 | }); 21 | 22 | }); 23 | -------------------------------------------------------------------------------- /worker/test/subscribable-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { WorkerAnnotations, SubscribableMetaData } from '../../common/src/public-api'; 2 | import { Subscribable } from '../src/public-api'; 3 | import { Subject } from 'rxjs'; 4 | 5 | class TestClass { 6 | @Subscribable() 7 | public event: Subject; 8 | } 9 | 10 | describe('@Subscribable(): [angular-web-worker]', () => { 11 | 12 | it('Should attach metadata to the class prototype', () => { 13 | expect(TestClass[WorkerAnnotations.Annotation][WorkerAnnotations.Observables].length).toEqual(1); 14 | }); 15 | 16 | it('Should attach metadata with the property name', () => { 17 | expect((TestClass[WorkerAnnotations.Annotation][WorkerAnnotations.Observables][0] as SubscribableMetaData).name).toEqual('event'); 18 | }); 19 | 20 | it('Should attach metadata with the design type', () => { 21 | expect((TestClass[WorkerAnnotations.Annotation][WorkerAnnotations.Observables][0] as SubscribableMetaData).type).toEqual(Subject); 22 | }); 23 | 24 | }); 25 | -------------------------------------------------------------------------------- /worker/src/lib/subscribable-decorator.ts: -------------------------------------------------------------------------------- 1 | import { WorkerUtils, ObservablesOnly, SubscribableMetaData, WorkerAnnotations, WorkerConfig, SecretResult, WorkerEvents } from 'angular-web-worker/common'; 2 | import 'reflect-metadata'; 3 | 4 | /** 5 | * Allows the decorated worker property to be subscribed to, or observed through the `WorkerClient.subscribe()` and `WorkerClient.observe()` methods. 6 | * 7 | * Can only be used on multicasted RxJS observables being a `Subject`, `BehaviorSubject`, `ReplaySubject` or `AsyncSubject`. 8 | * @Serialized When data is transferred through `Subject.next()`, functions will not be copied and circular referencing structures will cause errors 9 | */ 10 | export function Subscribable() { 11 | return function >(target: T, propertyKey: Tkey) { 12 | WorkerUtils.pushAnnotation(target.constructor, WorkerAnnotations.Observables, { 13 | name: propertyKey, 14 | type: Reflect.getMetadata('design:type', target, propertyKey) 15 | }); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Gavin Leo-Smith 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 | -------------------------------------------------------------------------------- /tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "angularCompilerOptions": { 4 | "skipTemplateCodegen": true, 5 | "strictMetadataEmit": true, 6 | "fullTemplateTypeCheck": true, 7 | "enableResourceInlining": true 8 | }, 9 | "buildOnSave": false, 10 | "compileOnSave": false, 11 | "compilerOptions": { 12 | "baseUrl": ".", 13 | "target": "es2015", 14 | "module": "es2015", 15 | "outDir": "AUTOGENERATED", 16 | "declaration": true, 17 | "declarationDir": "AUTOGENERATED", 18 | "inlineSourceMap": true, 19 | "inlineSources": true, 20 | "skipLibCheck": true, 21 | "emitDecoratorMetadata": true, 22 | "experimentalDecorators": true, 23 | "importHelpers": true, 24 | "lib": ["dom", "es2018"], 25 | "paths": { 26 | "angular-web-worker": [ 27 | "./dist" 28 | ], 29 | "angular-web-worker/*": [ 30 | "./dist/*" 31 | ] 32 | } 33 | }, 34 | "exclude": [ "schematics/*/**.*", "**/*.ngfactory.ts", "**/*.shim.ts", "**/*.spec.ts", "dist"], 35 | "files": ["AUTOGENERATED"] 36 | } 37 | -------------------------------------------------------------------------------- /angular/test/worker-manager.spec.ts: -------------------------------------------------------------------------------- 1 | import { WorkerManager, WorkerDefinition, WorkerClient } from './../src/public-api'; 2 | import { AngularWebWorker } from './../../worker/src/public-api'; 3 | 4 | @AngularWebWorker() 5 | class TestClass { 6 | name: string = 'random'; 7 | } 8 | 9 | @AngularWebWorker() 10 | class TestClass2 { 11 | name: string = 'random22'; 12 | } 13 | 14 | describe('WorkerManager: [angular-web-worker/angular]', () => { 15 | 16 | let manager: WorkerManager; 17 | function privateWorkerDefintion(client: WorkerClient): WorkerDefinition { 18 | return client['definition']; 19 | } 20 | 21 | beforeEach(() => { 22 | manager = new WorkerManager([{worker: TestClass, initFn: null}]); 23 | }); 24 | 25 | it('Should create a new worker client with a defintion', () => { 26 | const client = manager.createClient(TestClass); 27 | expect(privateWorkerDefintion(client)).toEqual({worker: TestClass, initFn: null}); 28 | }); 29 | 30 | it('Should throw an error if the worker class argument does not have a definition', () => { 31 | expect(() => manager.createClient(TestClass2)).toThrowError(); 32 | }); 33 | 34 | }); 35 | -------------------------------------------------------------------------------- /schematics/src/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "id": "angular-web-worker-schema", 4 | "title": "Schema for angular-web-worker options ", 5 | "type": "object", 6 | "description": "Creates a new angular-web-worker", 7 | "properties": { 8 | "name": { 9 | "type": "string", 10 | "description": "The name of the worker", 11 | "$default": { 12 | "$source": "argv", 13 | "index": 0 14 | }, 15 | "x-prompt": "Provide a name for the worker?" 16 | }, 17 | "path": { 18 | "type": "string", 19 | "format": "path", 20 | "description": "The path at which to create the worker file, relative to the current workspace.", 21 | "visible": false 22 | }, 23 | "project": { 24 | "type": "string", 25 | "description": "The name of the project.", 26 | "$default": { 27 | "$source": "projectName" 28 | } 29 | }, 30 | "target": { 31 | "type": "string", 32 | "description": "The target to apply web worker to.", 33 | "default": "build" 34 | } 35 | }, 36 | "required": [ 37 | "name" 38 | ] 39 | } -------------------------------------------------------------------------------- /testing/src/lib/worker-testing-module.ts: -------------------------------------------------------------------------------- 1 | import { WorkerModule, WorkerManager } from 'angular-web-worker/angular'; 2 | import { WebWorkerType, WorkerUtils, WorkerAnnotations } from 'angular-web-worker/common'; 3 | import { WorkerTestingManager } from './worker-testing-manager'; 4 | 5 | /** 6 | * **Used for Testing** 7 | * 8 | * Testing implementation a `WorkerModule`, which provides a `WorkerTestingManager` that creates testable worker client that dos not run in a worker script but mocks the serialization that occurs when messages are transfered to 9 | * and from a worker. 10 | */ 11 | export class WorkerTestingModule { 12 | 13 | static forWorkers(workers: WebWorkerType[]) { 14 | 15 | workers.forEach((wkr) => { 16 | if (!WorkerUtils.getAnnotation(wkr, WorkerAnnotations.IsWorker)) { 17 | throw new Error('WorkerModule: one or more of the provided workers has not been decorated with the @AngularWebWorker decorator'); 18 | } 19 | }); 20 | 21 | return { 22 | ngModule: WorkerModule, 23 | providers: [ 24 | { provide: WorkerManager, useValue: new WorkerTestingManager(workers) } 25 | ] 26 | }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /testing/test/worker-testing-manager.spec.ts: -------------------------------------------------------------------------------- 1 | import { WorkerTestingManager, createTestManager } from '../src/lib/worker-testing-manager'; 2 | import { WorkerTestingClient } from 'testing/src/public-api'; 3 | import { AngularWebWorker } from '../../worker/src/lib/web-worker-decorator'; 4 | 5 | @AngularWebWorker() 6 | class TestClass { 7 | property: string = 'propertyvalue'; 8 | } 9 | 10 | @AngularWebWorker() 11 | class UndecoratedClass { 12 | } 13 | 14 | describe('WorkerTestingManager: [angular-web-worker/testing]', () => { 15 | 16 | let manager: WorkerTestingManager; 17 | beforeEach(() => { 18 | manager = new WorkerTestingManager([TestClass]); 19 | }); 20 | 21 | it('Should to create a new instance of a worker client', () => { 22 | expect(manager.createClient(TestClass) instanceof WorkerTestingClient).toEqual(true); 23 | }); 24 | 25 | it('Should through an error if no worker classes are provided', async () => { 26 | expect(() => new WorkerTestingManager(null)).toThrowError(); 27 | }); 28 | 29 | }); 30 | 31 | describe('createTestManager(): [angular-web-worker/testing]', () => { 32 | 33 | it('Should create a new instance of a TestWorkerManager', () => { 34 | expect(createTestManager([TestClass]) instanceof WorkerTestingManager).toEqual(true); 35 | }); 36 | 37 | }); 38 | -------------------------------------------------------------------------------- /worker/test/shallow-transfer-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { ShallowTransfer } from '../src/public-api'; 2 | import { WorkerAnnotations, ShallowTransferParamMetaData } from '../../common/src/public-api'; 3 | 4 | 5 | class TestClass { 6 | doSomething(name: string, @ShallowTransfer() age: number) { 7 | } 8 | } 9 | 10 | describe('@ShallowTransfer() [angular-web-worker]', () => { 11 | 12 | it('Should attach metadata to the class prototype', () => { 13 | expect(TestClass[WorkerAnnotations.Annotation][WorkerAnnotations.ShallowTransferArgs].length).toEqual(1); 14 | }); 15 | 16 | it('Should attach metadata with the method name', () => { 17 | expect((TestClass[WorkerAnnotations.Annotation][WorkerAnnotations.ShallowTransferArgs][0] as ShallowTransferParamMetaData).name).toEqual('doSomething'); 18 | }); 19 | 20 | it('Should attach metadata with the argument type', () => { 21 | expect((TestClass[WorkerAnnotations.Annotation][WorkerAnnotations.ShallowTransferArgs][0] as ShallowTransferParamMetaData).type).toEqual(Number); 22 | }); 23 | 24 | it('Should attach metadata with the argument index', () => { 25 | expect((TestClass[WorkerAnnotations.Annotation][WorkerAnnotations.ShallowTransferArgs][0] as ShallowTransferParamMetaData).argIndex).toEqual(1); 26 | }); 27 | 28 | }); 29 | -------------------------------------------------------------------------------- /angular/test/worker-module.spec.ts: -------------------------------------------------------------------------------- 1 | import { AngularWebWorker } from './../../worker/src/public-api'; 2 | import { WorkerModule, WorkerManager } from './../src/public-api'; 3 | import { TestBed } from '@angular/core/testing'; 4 | import { platformBrowserDynamicTesting, BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; 5 | 6 | 7 | @AngularWebWorker() 8 | class TestClass { 9 | } 10 | 11 | class UndecoratedTestClass { 12 | } 13 | 14 | describe('WorkerModule: [angular-web-worker/angular]', () => { 15 | 16 | beforeEach(async () => { 17 | TestBed.resetTestEnvironment(); 18 | TestBed.initTestEnvironment(BrowserDynamicTestingModule, 19 | platformBrowserDynamicTesting()); 20 | }); 21 | 22 | it('Should return a module with a WorkerManager provider ', () => { 23 | TestBed.configureTestingModule({ 24 | imports: [ 25 | WorkerModule.forWorkers([{ worker: TestClass, initFn: null }]) 26 | ] 27 | }); 28 | const service = TestBed.get(WorkerManager); 29 | expect(service).toEqual(new WorkerManager([{ worker: TestClass, initFn: null }])); 30 | }); 31 | 32 | it('Should throw an error when undecorated worker definitions are provided', () => { 33 | expect(() => WorkerModule.forWorkers([{ worker: TestClass, initFn: null }, { worker: UndecoratedTestClass, initFn: () => null }])).toThrowError(); 34 | }); 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /testing/test/worker-testing-client.spec.ts: -------------------------------------------------------------------------------- 1 | import { AngularWebWorker } from '../../worker/src/public-api'; 2 | import { WorkerTestingClient, createTestClient } from 'testing/src/public-api'; 3 | 4 | @AngularWebWorker() 5 | class TestClass { 6 | property: string = 'propertyvalue'; 7 | } 8 | 9 | class UndecoratedClass { 10 | } 11 | 12 | describe('WorkerTestingClient: [angular-web-worker/testing]', () => { 13 | 14 | let worker: WorkerTestingClient; 15 | beforeEach(() => { 16 | worker = new WorkerTestingClient({worker: TestClass, initFn: () => null}); 17 | }); 18 | 19 | it('Should be configured for testing', () => { 20 | expect(worker['isTestClient']).toEqual(true); 21 | expect(worker['runInApp']).toEqual(true); 22 | }); 23 | 24 | it('Should provide access to the underlying worker instance', async () => { 25 | await worker.connect(); 26 | expect(worker.workerInstance instanceof TestClass).toEqual(true); 27 | }, 200); 28 | 29 | }); 30 | 31 | describe('createTestWorker(): [angular-web-worker/testing]', () => { 32 | 33 | it('Should create a new instance of a TestWorkerClient', () => { 34 | expect(createTestClient(TestClass) instanceof WorkerTestingClient).toEqual(true); 35 | }); 36 | 37 | it('Should throw an error if an undecorated class is provided', () => { 38 | expect(() => createTestClient(UndecoratedClass)).toThrow(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /testing/test/worker-testing-module.spec.ts: -------------------------------------------------------------------------------- 1 | import { AngularWebWorker } from './../../worker/src/public-api'; 2 | import { WorkerTestingModule } from './../src/public-api'; 3 | import { TestBed } from '@angular/core/testing'; 4 | import { WorkerManager } from 'angular-web-worker/angular'; 5 | import { WorkerTestingManager } from 'testing/src/lib/worker-testing-manager'; 6 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 7 | 8 | 9 | @AngularWebWorker() 10 | class TestClass { 11 | } 12 | 13 | class UndecoratedTestClass { 14 | } 15 | 16 | describe('WorkerTestingModule: [angular-web-worker/testing]', () => { 17 | 18 | beforeEach(async () => { 19 | TestBed.resetTestEnvironment(); 20 | TestBed.initTestEnvironment(BrowserDynamicTestingModule, 21 | platformBrowserDynamicTesting()); 22 | }); 23 | 24 | it('Should return a module with a WorkerManager provider with a WorkerTestingManager', () => { 25 | TestBed.configureTestingModule({ 26 | imports: [ 27 | WorkerTestingModule.forWorkers([TestClass]) 28 | ] 29 | }); 30 | const service = TestBed.get(WorkerManager); 31 | expect(service instanceof WorkerTestingManager).toEqual(true); 32 | }); 33 | 34 | it('Should throw an error when undecorated worker definitions are provided', () => { 35 | expect(() => WorkerTestingModule.forWorkers([TestClass, UndecoratedTestClass])).toThrowError(); 36 | }); 37 | 38 | }); 39 | -------------------------------------------------------------------------------- /testing/src/lib/worker-testing-client.ts: -------------------------------------------------------------------------------- 1 | import { WorkerClient, WorkerDefinition, ClientWebWorker } from 'angular-web-worker/angular'; 2 | import { WebWorkerType, WorkerUtils, WorkerAnnotations } from 'angular-web-worker/common'; 3 | 4 | /** 5 | * **Used for Testing** 6 | * 7 | * Testing implementation a `WorkerClient`, which does not run in a worker script but mocks the serialization that occurs when messages are transfered to 8 | * and from a worker. Also adds a public `workerInstance` to test and spy on the worker class 9 | * 10 | */ 11 | export class WorkerTestingClient extends WorkerClient { 12 | 13 | constructor(definition: WorkerDefinition) { 14 | super(definition, true, true); 15 | } 16 | 17 | /** 18 | * Exposed instance of the private worker instance to allow testing & spying 19 | */ 20 | get workerInstance(): T { 21 | if (this.isConnected) { 22 | return (this['workerRef'] as ClientWebWorker).workerInstance; 23 | } else { 24 | throw new Error('Cannot access worker instance until the connect method has been called'); 25 | } 26 | } 27 | } 28 | 29 | /** 30 | * Creates a new `TestWorkerClient` 31 | * @param workerClass worker class 32 | */ 33 | export function createTestClient(workerClass: WebWorkerType): WorkerTestingClient { 34 | if (!WorkerUtils.getAnnotation(workerClass, WorkerAnnotations.IsWorker)) { 35 | throw new Error('createTestClient: the provided class must be decorated with @AngularWebWorker()'); 36 | } else { 37 | return new WorkerTestingClient({ worker: workerClass, initFn: () => null }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /testing/src/lib/worker-testing-manager.ts: -------------------------------------------------------------------------------- 1 | import { WorkerManager, WorkerClient } from 'angular-web-worker/angular'; 2 | import { WebWorkerType } from 'angular-web-worker/common'; 3 | import { WorkerTestingClient } from './worker-testing-client'; 4 | 5 | /** 6 | * **Used for Testing** 7 | * 8 | * Testing implementation of the `WorkerManager` service, overriding the `createClient()` method to create a testable instance of the 9 | * `WorkerClient` 10 | * 11 | */ 12 | export class WorkerTestingManager extends WorkerManager { 13 | 14 | constructor(private workers: WebWorkerType[]) { 15 | 16 | super(workers.map(x => { 17 | return { worker: x, initFn: () => null }; 18 | })); 19 | 20 | if (!workers) { 21 | throw new Error('the workers argument for the TestWorkerManager constructor cannot be undefined or null'); 22 | } 23 | } 24 | 25 | createClient(workerType: WebWorkerType, runInApp: boolean = false): WorkerClient { 26 | const definition = this.workers.filter(p => p === workerType)[0]; 27 | if (definition) { 28 | return new WorkerTestingClient({ worker: workerType, initFn: () => null }); 29 | } else { 30 | throw new Error('WorkerManager: all web workers must be registered in the createTestManager function'); 31 | } 32 | } 33 | 34 | } 35 | 36 | /** 37 | * Creates a new `TestWorkerManager` 38 | * @param workers array of workers that can be created through the `createClient` method 39 | */ 40 | export function createTestManager(workers: WebWorkerType[]): WorkerTestingManager { 41 | return new WorkerTestingManager(workers); 42 | } 43 | -------------------------------------------------------------------------------- /common/test/worker-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { WorkerUtils } from 'angular-web-worker/common'; 2 | import { WorkerAnnotations } from 'angular-web-worker/common'; 3 | 4 | class TestClass { 5 | constructor() { 6 | } 7 | } 8 | 9 | describe('WorkerUtils: [angular-web-worker/common]', () => { 10 | 11 | let cls: TestClass; 12 | const annotationProperty = 'testProperty'; 13 | 14 | beforeEach(() => { 15 | cls = new TestClass(); 16 | }); 17 | 18 | it('Should set annotations', () => { 19 | WorkerUtils.setAnnotation(cls, annotationProperty, 10); 20 | expect(cls[WorkerAnnotations.Annotation][annotationProperty]).toEqual(10); 21 | }); 22 | 23 | it('Should add an item to an annotation array', () => { 24 | WorkerUtils.pushAnnotation(cls, annotationProperty, 'value 1'); 25 | expect(cls[WorkerAnnotations.Annotation][annotationProperty].length).toEqual(1); 26 | WorkerUtils.pushAnnotation(cls, annotationProperty, 'value 2'); 27 | expect(cls[WorkerAnnotations.Annotation][annotationProperty].length).toEqual(2); 28 | }); 29 | 30 | it('Should get annotations', () => { 31 | const annotationValue = { 32 | property: 'value' 33 | }; 34 | cls[WorkerAnnotations.Annotation] = {}; 35 | cls[WorkerAnnotations.Annotation][annotationProperty] = annotationValue; 36 | expect(WorkerUtils.getAnnotation(cls, annotationProperty)).toEqual(annotationValue); 37 | }); 38 | 39 | it('Should return the correct value if no annotation exists', () => { 40 | const undefinedValue = []; 41 | expect(WorkerUtils.getAnnotation(cls, annotationProperty)).toEqual(null); 42 | expect(WorkerUtils.getAnnotation(cls, annotationProperty, undefinedValue)).toEqual(undefinedValue); 43 | }); 44 | 45 | }); 46 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 2 | const path = require('path'); 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine'], 8 | files: [ 9 | './tests.ts', 10 | "**/*.spec.ts" 11 | ], 12 | mode: 'development', 13 | reporters: ['kjhtml'], 14 | preprocessors: { 15 | '**/*.ts': ['webpack'] 16 | }, 17 | webpack: { 18 | node: { 19 | fs: 'empty', 20 | child_process: 'empty' 21 | }, 22 | resolve: { 23 | extensions: ['.js', '.ts', '.tsx'], 24 | plugins: [ 25 | new TsconfigPathsPlugin({ configFile: "./tsconfig.spec.json" }) 26 | ], 27 | alias: { 28 | 'angular-web-worker/common': path.resolve(__dirname, 'common/src/public-api.ts'), 29 | } 30 | }, 31 | mode: 'development', 32 | stats: { 33 | warnings: false 34 | }, 35 | module: { 36 | rules: [ 37 | { 38 | test: /\.tsx?$/, 39 | exclude: [/node_modules/], 40 | use: { 41 | loader: 'ts-loader', 42 | options: { 43 | configFile: "tsconfig.spec.json" 44 | } 45 | } 46 | }, 47 | ] 48 | }, 49 | }, 50 | port: 9867, 51 | browsers: ['chrome'], 52 | logLevel: config.LOG_INFO, 53 | colors: true, 54 | client: { 55 | clearContext: false, 56 | }, 57 | coverageInstanbulReporter: { 58 | reports: ['html', 'lcovonly'] 59 | }, 60 | autoWatch: true, 61 | browsers: ['Chrome'], 62 | singleRun: false 63 | }); 64 | }; -------------------------------------------------------------------------------- /common/src/lib/worker-types.ts: -------------------------------------------------------------------------------- 1 | import { Observable, BehaviorSubject, Subject, AsyncSubject, ReplaySubject } from 'rxjs'; 2 | 3 | /** 4 | * A type interface to specify the prototype of any class that can be used as a web worker when decorated with `@AngularWebWorker()` 5 | */ 6 | export interface WebWorkerType extends Function { 7 | new(...args: any[]): T; 8 | } 9 | 10 | /** 11 | * The names of methods/functions from any class provided as a generic type argument 12 | */ 13 | export type FunctionPropertyNames = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T]; 14 | 15 | /** 16 | * Selection of class methods/functions where the class is provided as a generic type argument 17 | */ 18 | export type FunctionsOnly = Pick>; 19 | 20 | /** 21 | * The names of properties in a particular class that are neither methods nor observables where the class is provided as a generic type argument 22 | */ 23 | export type NonObservablePropertyNames = { [K in keyof T]: T[K] extends Observable ? never 24 | : T[K] extends Function ? never : K }[keyof T]; 25 | /** 26 | * Selection class properties that are neither methods nor observables where the class is provided as a generic type argument 27 | */ 28 | export type NonObservablesOnly = Pick>; 29 | 30 | /** 31 | * The names of class properties that are multicasted RxJS observables, being a `Subject`, `BehaviorSubject`, `AsyncSubject` or `ReplaySubject`. 32 | * The class is provided as a generic type argument 33 | */ 34 | export type ObservablePropertyNames = { [K in keyof T]: T[K] extends 35 | (BehaviorSubject | Subject | AsyncSubject | ReplaySubject) ? K : never; 36 | }[keyof T]; 37 | 38 | /** 39 | * Selection of class properties that are multicasted RxJS observables, being a `Subject`, `BehaviorSubject`, `AsyncSubject` or `ReplaySubject`. 40 | * The class is provided as a generic type argument 41 | */ 42 | export type ObservablesOnly = Pick>; 43 | 44 | /** 45 | * A type of RxJS observable 46 | */ 47 | export type WorkerObservableType = Observable; 48 | 49 | 50 | -------------------------------------------------------------------------------- /worker/src/lib/accessable-decorator.ts: -------------------------------------------------------------------------------- 1 | import { WorkerUtils, AccessableMetaData, WorkerAnnotations } from 'angular-web-worker/common'; 2 | import 'reflect-metadata'; 3 | 4 | /** 5 | * Configurable options for the `@Accessable()` decorator, defining how the decorated property can be interacted with from a `WorkerClient`. 6 | */ 7 | export interface AccessableOpts { 8 | /** 9 | * Determines whether the decorated property can be retrieved by a `WorkerClient` with its `get()` method 10 | * @defaultvalue true 11 | */ 12 | get?: boolean; 13 | /** 14 | * Determines whether the decorated property can be set by a `WorkerClient` with its `set()` method 15 | * @defaultvalue true 16 | */ 17 | set?: boolean; 18 | /** 19 | * Whether the decoratored property's prototype is transfered after it has been serialized and unserialized. 20 | * @defaultvalue false 21 | * @Experimental has limitations 22 | */ 23 | shallowTransfer?: boolean; 24 | } 25 | 26 | /** 27 | * Allows the decorated worker property to be accessed from the `WorkerClient.get()` and `WorkerClient.set()` methods 28 | * @Serialized Functions will not be copied and circular referencing structures will cause errors 29 | * @param options configurable options defining how the decorated property can be interacted with from a `WorkerClient` 30 | */ 31 | export function Accessable(options?: AccessableOpts) { 32 | 33 | const opts: AccessableOpts = { get: true, set: true, shallowTransfer: false }; 34 | if (options) { 35 | opts.get = options.get === false ? false : true; 36 | opts.set = options.set === false ? false : true; 37 | opts.shallowTransfer = options.shallowTransfer ? true : false; 38 | } 39 | 40 | return function (target: any, propertyKey: string) { 41 | WorkerUtils.pushAnnotation(target.constructor, WorkerAnnotations.Accessables, { 42 | name: propertyKey, 43 | type: Reflect.getMetadata('design:type', target, propertyKey), 44 | get: opts.get, 45 | set: opts.set, 46 | shallowTransfer: opts.shallowTransfer 47 | }); 48 | }; 49 | 50 | } 51 | 52 | -------------------------------------------------------------------------------- /worker/test/accessable-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { Accessable } from '../src/public-api'; 2 | import { WorkerAnnotations, AccessableMetaData } from '../../common/src/public-api'; 3 | 4 | class TestClassWithoutOptions { 5 | @Accessable() 6 | public property: string; 7 | } 8 | 9 | class TestClassWithOptions { 10 | @Accessable({ 11 | get: false, 12 | set: false, 13 | shallowTransfer: true 14 | }) 15 | public property: string; 16 | public property2: string; 17 | constructor() { } 18 | } 19 | 20 | 21 | describe('@Accessable(): [angular-web-worker]', () => { 22 | 23 | it('Should attach metadata to the class prototype', () => { 24 | expect(TestClassWithoutOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Accessables].length).toEqual(1); 25 | }); 26 | 27 | it('Should attach metadata with the property name', () => { 28 | expect((TestClassWithoutOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Accessables][0] as AccessableMetaData).name).toEqual('property'); 29 | }); 30 | 31 | it('Should attach metadata with the design type', () => { 32 | expect((TestClassWithoutOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Accessables][0] as AccessableMetaData).type).toEqual(String); 33 | }); 34 | 35 | it('Should attach metadata with the default options', () => { 36 | expect((TestClassWithoutOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Accessables][0] as AccessableMetaData).get).toEqual(true); 37 | expect((TestClassWithoutOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Accessables][0] as AccessableMetaData).set).toEqual(true); 38 | expect((TestClassWithoutOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Accessables][0] as AccessableMetaData).shallowTransfer).toEqual(false); 39 | }); 40 | 41 | it('Should attach metadata with the provided options', () => { 42 | expect((TestClassWithOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Accessables][0] as AccessableMetaData).get).toEqual(false); 43 | expect((TestClassWithOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Accessables][0] as AccessableMetaData).set).toEqual(false); 44 | expect((TestClassWithOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Accessables][0] as AccessableMetaData).shallowTransfer).toEqual(true); 45 | }); 46 | 47 | }); 48 | -------------------------------------------------------------------------------- /angular/src/lib/worker.module.ts: -------------------------------------------------------------------------------- 1 | 2 | import { WorkerManager } from './worker-manager'; 3 | import { ModuleWithProviders, NgModule } from '@angular/core'; 4 | import { WorkerUtils, WebWorkerType, WorkerAnnotations } from 'angular-web-worker/common'; 5 | 6 | /** 7 | * Provides the `WorkerManager` service with the worker definitions passed into the static `forWorkers` method. 8 | * @example 9 | * imports: [ 10 | * WorkerModule.forWorkers([ 11 | * {worker: AppWorker, initFn: () => new Worker('./app.worker.ts', {type: 'module'})}, 12 | * ]) 13 | * ] 14 | */ 15 | @NgModule() 16 | export class WorkerModule { 17 | 18 | /** 19 | * Returns a module with a `WorkerManager` provider 20 | * @param workerDefinitions list of worker defintions which contain the worker class and an `initFn` function which is necessary for the 21 | * webpack `worker-plugin` to bundle the worker seperately. 22 | * @example 23 | * imports: [ 24 | * WorkerModule.forWorkers([ 25 | * {worker: AppWorker, initFn: () => new Worker('./app.worker.ts', {type: 'module'})}, 26 | * ]) 27 | * ] 28 | */ 29 | static forWorkers(workerDefinitions: WorkerDefinition[]): ModuleWithProviders { 30 | 31 | workerDefinitions.forEach((definition) => { 32 | if (!WorkerUtils.getAnnotation(definition.worker, WorkerAnnotations.IsWorker)) { 33 | throw new Error('WorkerModule: one or more of the provided workers has not been decorated with the @AngularWebWorker decorator'); 34 | } 35 | }); 36 | 37 | return { 38 | ngModule: WorkerModule, 39 | providers: [ 40 | { provide: WorkerManager, useValue: new WorkerManager(workerDefinitions) } 41 | ] 42 | }; 43 | } 44 | 45 | } 46 | 47 | /** 48 | * A definition of a worker that is required to create new worker instances 49 | */ 50 | export interface WorkerDefinition { 51 | /** 52 | * the worker class which has been decorated with `@AngularWebWorker()` 53 | */ 54 | worker: WebWorkerType; 55 | /** 56 | * A function that creates a worker. This is required for the webpack `worker-plugin` to bundle the worker seperately and is used by a `WorkerClient` 57 | * to create a new worker 58 | * 59 | * **IMPORTANT** 60 | * 61 | * The syntax is crucial for the webpack plugin. The path must be a string and the {type: 'module'} argument must be given 62 | * @example 63 | * () => new Worker('./app.worker.ts', {type: 'module'}) 64 | */ 65 | initFn: () => Worker; 66 | } 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /angular/test/client-web-worker.spec.ts: -------------------------------------------------------------------------------- 1 | import { ClientWebWorker } from './../src/public-api'; 2 | import { AngularWebWorker, WorkerController } from '../../worker/src/public-api'; 3 | import { WorkerEvents } from 'angular-web-worker/common'; 4 | 5 | @AngularWebWorker() 6 | export class TestClass { 7 | } 8 | 9 | describe('ClientWebWorker: [angular-web-worker/angular]', () => { 10 | 11 | let worker: ClientWebWorker; 12 | beforeEach(() => { 13 | worker = new ClientWebWorker(TestClass, true); 14 | }); 15 | 16 | it('Should create a web worker controller instance', () => { 17 | expect(worker['controller'] instanceof WorkerController).toEqual(true); 18 | }); 19 | 20 | it('Should transfer messages from a controller to a client', () => { 21 | const spy = spyOn(worker, 'onmessage'); 22 | worker['messageBus'].postMessage({ bodyProperty: 'value' }); 23 | expect((spy.calls.mostRecent().args[0] as MessageEvent).data).toEqual({ bodyProperty: 'value' }); 24 | }); 25 | 26 | it('Should transfer messages from a client to a controller', () => { 27 | const spy = spyOn(worker['messageBus'], 'onmessage'); 28 | worker.postMessage({ bodyProperty: 'value' }); 29 | expect((spy.calls.mostRecent().args[0] as MessageEvent).data).toEqual({ bodyProperty: 'value' }); 30 | }); 31 | 32 | it('postMessage should serialise if configured for testing', () => { 33 | const spy = spyOn(worker, 'serialize'); 34 | spyOn(worker['messageBus'], 'onmessage'); 35 | worker.postMessage({ bodyProperty: 'value' }); 36 | expect(spy).toHaveBeenCalled(); 37 | }); 38 | 39 | it('postMessage should not serialise if not configured for testing', () => { 40 | worker = new ClientWebWorker(TestClass, false); 41 | const spy = spyOn(worker, 'serialize'); 42 | worker.postMessage({ bodyProperty: 'value' }); 43 | expect(spy).not.toHaveBeenCalled(); 44 | }); 45 | 46 | it('messageBus.postMessage should serialise if configured for testing', () => { 47 | const spy = spyOn(worker, 'serialize'); 48 | worker['messageBus'].postMessage({ bodyProperty: 'value' }); 49 | expect(spy).toHaveBeenCalled(); 50 | }); 51 | 52 | it('messageBus.postMessage should not serialise if not configured for testing', () => { 53 | worker = new ClientWebWorker(TestClass, false); 54 | const spy = spyOn(worker, 'serialize'); 55 | worker['messageBus'].postMessage({ bodyProperty: 'value' }); 56 | expect(spy).not.toHaveBeenCalled(); 57 | }); 58 | 59 | }); 60 | -------------------------------------------------------------------------------- /common/src/lib/worker-utils.ts: -------------------------------------------------------------------------------- 1 | import { WorkerAnnotations, WorkerConfig } from './annotations'; 2 | 3 | /** 4 | * A set of static utility functions for creating and retrieving worker annotations 5 | */ 6 | export class WorkerUtils { 7 | 8 | /** 9 | * Creates or replaces a worker annotation 10 | * @param cls Class or object that the annotations will be attached to 11 | * @param propertyKey name of the annotated property 12 | * @param value the value of the annotation 13 | */ 14 | static setAnnotation(cls: any, propertyKey: string, value: any): void { 15 | if (cls.hasOwnProperty(WorkerAnnotations.Annotation)) { 16 | cls[WorkerAnnotations.Annotation][propertyKey] = value; 17 | } else { 18 | Object.defineProperty(cls, WorkerAnnotations.Annotation, { 19 | value: {} 20 | }); 21 | WorkerUtils.setAnnotation(cls, propertyKey, value); 22 | } 23 | } 24 | 25 | 26 | /** 27 | * Adds an item to an array for a particular annotation property. If no array exists a new array will be created before the item is added 28 | * @param cls Class or object that the annotations will be attached to 29 | * @param propertyKey name of the annotated array 30 | * @param value the item to add in the array 31 | */ 32 | static pushAnnotation(cls: any, propertyKey: string, value: any): void { 33 | if (cls.hasOwnProperty(WorkerAnnotations.Annotation)) { 34 | if (cls[WorkerAnnotations.Annotation].hasOwnProperty(propertyKey)) { 35 | cls[WorkerAnnotations.Annotation][propertyKey].push(value); 36 | } else { 37 | cls[WorkerAnnotations.Annotation][propertyKey] = []; 38 | cls[WorkerAnnotations.Annotation][propertyKey].push(value); 39 | } 40 | } else { 41 | Object.defineProperty(cls, WorkerAnnotations.Annotation, { 42 | value: {} 43 | }); 44 | WorkerUtils.pushAnnotation(cls, propertyKey, value); 45 | } 46 | } 47 | 48 | /** 49 | * Returns an annotated worker property. Allows for a generic type argument to specify the return type of the annotation 50 | * @param cls Class or object that the annotations is attached to 51 | * @param propertyKey name of the annotated array 52 | * @param ifUndefined the returned value if the annotated property does not exist 53 | */ 54 | static getAnnotation(cls: any, propertyKey: string, ifUndefined = null): T { 55 | if (cls.hasOwnProperty(WorkerAnnotations.Annotation)) { 56 | return cls[WorkerAnnotations.Annotation][propertyKey]; 57 | } else { 58 | return ifUndefined; 59 | } 60 | } 61 | 62 | } 63 | 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-web-worker", 3 | "version": "1.0.2", 4 | "description": "Library to assist with web worker communication in Angular apps", 5 | "main": "y", 6 | "scripts": { 7 | "test": "karma start karma.conf.js", 8 | "build:pkg": "ts-node package-script.ts --force", 9 | "build:schematics": "npm run remove:schematics && tsc -p ./schematics/tsconfig.schematics.json && npm run move:schematics", 10 | "move:schematics": "copyfiles -f ./schematics/package.json ./dist/schematics && copyfiles -f ./schematics/collection.json ./dist/schematics && copyfiles -u 3 ./schematics/src/files/**/*.* ./dist/schematics/src/files/ && copyfiles -f ./schematics/src/schema.json ./dist/schematics/src ", 11 | "remove:schematics": "rimraf ./dist/schematics", 12 | "build": "npm run build:pkg && npm run build:schematics" 13 | }, 14 | "schematics": "./schematics/collection.json", 15 | "author": { 16 | "name" : "Gavin Leo-Smith", 17 | "email" : "gavin@gleo-smith.co.za" 18 | }, 19 | "license": "MIT", 20 | "keywords": [ 21 | "angular", 22 | "webworker", 23 | "web worker", 24 | "typescript" 25 | ], 26 | 27 | "bugs": { 28 | "url": "https://github.com/gleosmith/angular-web-worker/issues" 29 | }, 30 | "homepage": "https://github.com/gleosmith/angular-web-worker#readme", 31 | "repository": { 32 | "type" : "git", 33 | "url" : "https://github.com/gleosmith/angular-web-worker.git" 34 | }, 35 | "peerDependencies": { 36 | "reflect-metadata": "^0.1.13", 37 | "@angular/core": "~8.0.0" 38 | }, 39 | "devDependencies": { 40 | "@angular-devkit/core": "^8.0.0", 41 | "@angular-devkit/schematics": "^8.0.0", 42 | "@angular/common": "^8.0.0", 43 | "@angular/compiler": "^8.0.0", 44 | "@angular/compiler-cli": "^8.0.0", 45 | "@angular/core": "~8.0.0", 46 | "@angular/platform-browser": "^8.0.0", 47 | "@angular/platform-browser-dynamic": "^8.0.0", 48 | "@schematics/angular": "^8.0.0", 49 | "@types/jasmine": "^3.3.16", 50 | "@types/sinon": "^7.0.13", 51 | "copyfiles": "^2.1.1", 52 | "core-js": "^3.1.4", 53 | "jasmine-core": "~2.99.1", 54 | "jasmine-spec-reporter": "~4.2.1", 55 | "karma": "~3.1.1", 56 | "karma-chrome-launcher": "~2.2.0", 57 | "karma-cli": "^2.0.0", 58 | "karma-coverage-istanbul-reporter": "~2.0.1", 59 | "karma-jasmine": "~1.1.2", 60 | "karma-jasmine-html-reporter": "^0.2.2", 61 | "karma-sourcemap-loader": "^0.3.7", 62 | "karma-typescript": "^4.1.1", 63 | "karma-webpack": "^4.0.2", 64 | "ng-packagr": "^5.1.0", 65 | "rimraf": "^2.6.3", 66 | "ts-loader": "^6.0.4", 67 | "ts-node": "~7.0.0", 68 | "tsconfig-paths-webpack-plugin": "^3.2.0", 69 | "tslint": "^5.11.0", 70 | "typescript": "3.4.3", 71 | "webpack": "^4.38.0", 72 | "zone.js": "^0.10.0" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /worker/src/lib/callable-decorator.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { CallableMetaData, WorkerConfig, WorkerEvents, SecretResult, WorkerUtils, WorkerAnnotations } from 'angular-web-worker/common'; 3 | 4 | /** 5 | * Configurable options for the `@Callable()` decorator, defining how the decorated method is called from a `WorkerClient`. 6 | */ 7 | export interface CallableOpts { 8 | /** 9 | * Whether the prototype of the value returned by the decorated method is transfered after it has been serialized and unserialized when brought back to the `WorkerClient` 10 | * @defaultvalue false 11 | * @Experimental has limitations 12 | */ 13 | shallowTransfer?: boolean; 14 | } 15 | 16 | /** 17 | * Allows the decorated worker method to be called, and its value returned, from the `WorkerClient.call()` method. 18 | * Can be used on both asynchronous and synchronous methods. 19 | * @Serialized Functions will not be copied and circular referencing structures will cause errors. This applies to both the function arguments and the value returned by the function 20 | * @param options Configurable options defining how the decorated method is called from a `WorkerClient` 21 | */ 22 | export function Callable(options?: CallableOpts) { 23 | 24 | return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { 25 | 26 | const opts = { shallowTransfer: false }; 27 | if (options) { 28 | opts.shallowTransfer = options.shallowTransfer ? true : false; 29 | } 30 | 31 | WorkerUtils.pushAnnotation(target.constructor, WorkerAnnotations.Callables, { 32 | name: propertyKey, 33 | shallowTransfer: opts.shallowTransfer, 34 | returnType: Reflect.getMetadata('design:returntype', target, propertyKey) 35 | }); 36 | 37 | const originalMethod = descriptor.value; 38 | descriptor.value = function () { 39 | const context = this; 40 | const args = Array.prototype.slice.call(arguments); 41 | const config: WorkerConfig = context.__worker_config__; 42 | if (config) { 43 | if (config.isClient) { 44 | const secret: SecretResult = { 45 | clientSecret: context.__worker_config__.clientSecret, 46 | type: WorkerEvents.Callable, 47 | propertyName: propertyKey, 48 | body: { 49 | args: args 50 | } 51 | }; 52 | return secret; 53 | } else { 54 | return originalMethod.call(context, ...args); 55 | } 56 | } else { 57 | return originalMethod.call(context, ...args); 58 | } 59 | }; 60 | return descriptor; 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /worker/test/callable-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { Callable } from '../src/public-api'; 2 | import { WorkerAnnotations, CallableMetaData, WorkerConfig, SecretResult, WorkerEvents } from '../../common/src/public-api'; 3 | 4 | class TestClassWithoutOptions { 5 | @Callable() 6 | doSomething(value: string, value2: number): string { 7 | return value + String(value2); 8 | } 9 | } 10 | 11 | class TestClassWithOptions { 12 | @Callable({ shallowTransfer: true }) 13 | doSomething(value: string, value2: number): string { 14 | return value + String(value2); 15 | } 16 | } 17 | 18 | 19 | describe('@Callable(): [angular-web-worker]', () => { 20 | 21 | it('Should attach metadata to the class prototype', () => { 22 | expect(TestClassWithoutOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Callables].length).toEqual(1); 23 | }); 24 | 25 | it('Should attach metadata with the property name', () => { 26 | expect((TestClassWithoutOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Callables][0] as CallableMetaData).name).toEqual('doSomething'); 27 | }); 28 | 29 | it('Should attach metadata with the return type', () => { 30 | expect((TestClassWithoutOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Callables][0] as CallableMetaData).returnType).toEqual(String); 31 | }); 32 | 33 | it('Should attach metadata with the default options', () => { 34 | expect((TestClassWithoutOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Callables][0] as CallableMetaData).shallowTransfer).toEqual(false); 35 | }); 36 | 37 | it('Should attach metadata with the provided options', () => { 38 | expect((TestClassWithOptions[WorkerAnnotations.Annotation][WorkerAnnotations.Callables][0] as CallableMetaData).shallowTransfer).toEqual(true); 39 | }); 40 | 41 | it('For client instances, it should replace the function implementation to return a secret', () => { 42 | 43 | const instance = new TestClassWithOptions(); 44 | instance[WorkerAnnotations.Config] = { 45 | isClient: true, 46 | clientSecret: 'my-secret', 47 | }; 48 | 49 | const result: SecretResult = { 50 | propertyName: 'doSomething', 51 | type: WorkerEvents.Callable, 52 | clientSecret: 'my-secret', 53 | body: { 54 | args: ['hello', 1] 55 | } 56 | }; 57 | expect(instance.doSomething('hello', 1)).toEqual(result); 58 | }); 59 | 60 | it('For worker instances, it should not replace the function implementation', () => { 61 | const instance = new TestClassWithOptions(); 62 | instance[WorkerAnnotations.Config] = { 63 | isClient: false, 64 | }; 65 | expect(instance.doSomething('twelve', 12)).toEqual('twelve12'); 66 | }); 67 | 68 | it('For instances where no config has been set, it should not replace the function implementation', () => { 69 | const instance = new TestClassWithOptions(); 70 | expect(instance.doSomething('joe', 20)).toEqual('joe20'); 71 | }); 72 | 73 | }); 74 | -------------------------------------------------------------------------------- /angular/src/lib/client-web-worker.ts: -------------------------------------------------------------------------------- 1 | import { WorkerController } from 'angular-web-worker'; 2 | import { WebWorkerType, WorkerMessageBus } from 'angular-web-worker/common'; 3 | 4 | /** 5 | * Used to mock the behaviour of the native `Worker` class when a `WorkerClient` is set to run in the app and not in the worker script. 6 | * Controls the flow of messages to and from a `WorkerClient` and a `WorkerController` 7 | */ 8 | export class ClientWebWorker implements Worker { 9 | 10 | /** 11 | * Handles execution of code in a worker 12 | */ 13 | private controller: WorkerController; 14 | 15 | /** 16 | * Interface for message bus provided into a `WorkerController` allowing the communication mechanism to be interchanged between in-app, and native worker 17 | * communication mechansims 18 | */ 19 | private messageBus: WorkerMessageBus; 20 | 21 | /** 22 | * Creates a new instance of a `ClientWebWorker` 23 | * @param workerType the worker class 24 | * @param isTestClient whether the instance is used for testing which will then mock serialization 25 | */ 26 | constructor(workerType: WebWorkerType, private isTestClient: boolean) { 27 | this.messageBus = { 28 | onmessage: () => { }, 29 | postMessage: (resp: any) => { 30 | this.onmessage(new MessageEvent('ClientWebWorker', { data: this.isTestClient ? this.serialize(resp) : resp })); 31 | } 32 | }; 33 | this.controller = new WorkerController(workerType, this.messageBus); 34 | } 35 | 36 | /** 37 | * Returns instance of worker class 38 | */ 39 | get workerInstance(): T { 40 | return this.controller.workerInstance; 41 | } 42 | 43 | /** 44 | * Message listener for a `WorkerClient` 45 | */ 46 | onmessage(ev: MessageEvent) { 47 | } 48 | 49 | /** 50 | * Sends messages triggered from a `WorkerClient` to a `WorkerController` 51 | */ 52 | postMessage(resp: any) { 53 | this.messageBus.onmessage(new MessageEvent('ClientWebWorker', { data: this.isTestClient ? this.serialize(resp) : resp })); 54 | } 55 | 56 | /** 57 | * Unsubscribes from all subscriptions in the `WorkerController` and then destroys the controller 58 | */ 59 | terminate() { 60 | this.controller.removeAllSubscriptions(); 61 | this.controller = null; 62 | } 63 | 64 | /** 65 | * Used for testing to mock the serialization that occurs when native the postMessage or onmessage are used to communicate with a worker script 66 | * @param obj object to be serialised 67 | */ 68 | private serialize(obj: any): any { 69 | return JSON.parse(JSON.stringify(obj)); 70 | } 71 | 72 | 73 | /** 74 | * Ensures class conforms to the native `Worker` class 75 | * @NotImplemented 76 | */ 77 | onerror(err: any) { 78 | } 79 | 80 | /** 81 | * Ensures class conforms to the native `Worker` class 82 | * @NotImplemented 83 | */ 84 | addEventListener() { 85 | } 86 | 87 | /** 88 | * Ensures class conforms to the native `Worker` class 89 | * @NotImplemented 90 | */ 91 | removeEventListener() { 92 | } 93 | 94 | 95 | /** 96 | * Ensures class conforms to the native `Worker` class 97 | * @NotImplemented 98 | */ 99 | dispatchEvent(evt: Event): boolean { 100 | return true; 101 | } 102 | 103 | 104 | 105 | } 106 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warn", 3 | "rules": { 4 | "arrow-return-shorthand": false, 5 | "callable-types": true, 6 | "class-name": true, 7 | "comment-format": [ 8 | true, 9 | "check-space" 10 | ], 11 | "curly": true, 12 | "deprecation": { 13 | "severity": "warn" 14 | }, 15 | "eofline": true, 16 | "forin": true, 17 | "import-blacklist": [ 18 | true, 19 | "rxjs/Rx" 20 | ], 21 | "import-spacing": true, 22 | "indent": [ 23 | true, 24 | "spaces" 25 | ], 26 | "interface-over-type-literal": true, 27 | "label-position": true, 28 | "max-line-length": [ 29 | true, 30 | 300 31 | ], 32 | "member-access": false, 33 | "member-ordering": [ 34 | true, 35 | { 36 | "order": [ 37 | "static-field", 38 | "instance-field", 39 | "static-method", 40 | "instance-method" 41 | ] 42 | } 43 | ], 44 | "no-arg": true, 45 | "no-bitwise": true, 46 | "no-console": [ 47 | true, 48 | "debug", 49 | "info", 50 | "time", 51 | "timeEnd", 52 | "trace" 53 | ], 54 | "no-construct": true, 55 | "no-debugger": true, 56 | "no-duplicate-super": true, 57 | "no-empty": false, 58 | "no-empty-interface": true, 59 | "no-eval": true, 60 | "no-misused-new": true, 61 | "no-non-null-assertion": true, 62 | "no-redundant-jsdoc": true, 63 | "no-shadowed-variable": true, 64 | "no-string-literal": false, 65 | "no-string-throw": true, 66 | "no-switch-case-fall-through": true, 67 | "no-trailing-whitespace": true, 68 | "no-unnecessary-initializer": true, 69 | "no-unused-expression": true, 70 | "no-use-before-declare": true, 71 | "no-var-keyword": true, 72 | "object-literal-sort-keys": false, 73 | "one-line": [ 74 | true, 75 | "check-open-brace", 76 | "check-catch", 77 | "check-else", 78 | "check-whitespace" 79 | ], 80 | "prefer-const": true, 81 | "quotemark": [ 82 | true, 83 | "single" 84 | ], 85 | "radix": true, 86 | "semicolon": [ 87 | true, 88 | "always" 89 | ], 90 | "triple-equals": [ 91 | true, 92 | "allow-null-check" 93 | ], 94 | "typedef-whitespace": [ 95 | true, 96 | { 97 | "call-signature": "nospace", 98 | "index-signature": "nospace", 99 | "parameter": "nospace", 100 | "property-declaration": "nospace", 101 | "variable-declaration": "nospace" 102 | } 103 | ], 104 | "unified-signatures": true, 105 | "variable-name": false, 106 | "whitespace": [ 107 | true, 108 | "check-branch", 109 | "check-decl", 110 | "check-operator", 111 | "check-separator", 112 | "check-type" 113 | ], 114 | "no-output-on-prefix": true, 115 | "no-inputs-metadata-property": true, 116 | "no-outputs-metadata-property": true, 117 | "no-host-metadata-property": true, 118 | "no-input-rename": true, 119 | "no-output-rename": true, 120 | "use-lifecycle-interface": true, 121 | "use-pipe-transform-interface": true, 122 | "component-class-suffix": true, 123 | "directive-class-suffix": true 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /angular/src/lib/worker-manager.ts: -------------------------------------------------------------------------------- 1 | 2 | import { WorkerClient } from './worker-client'; 3 | import { WorkerDefinition } from './worker.module'; 4 | import { WebWorkerType } from 'angular-web-worker/common'; 5 | 6 | /** 7 | * Injectable angular service with a primary responsability of acting as `WorkerClient` factory through its `createClient()` method. 8 | * 9 | * **Module** 10 | * 11 | * The `WorkerModule` must be imported to provide the service, passing in worker defintions in the `WorkerModule.forWorkers()` function so that the factory method 12 | * has neccessary details to create new clients 13 | * 14 | * @example 15 | * // module --- 16 | * imports: [ 17 | * WorkerModule.forWorkers([ 18 | * {worker: AppWorker, initFn: () => new Worker('./app.worker.ts', {type: 'module'})}, 19 | * ]) 20 | * ] 21 | * 22 | * // usage --- 23 | * export class AppComponent implements OnInit { 24 | * 25 | * constructor(private workerManager: WorkerManager) {} 26 | * 27 | * ngOnInit() { 28 | * const client: WorkerClient = this.workerManager.createClient(AppWorker); 29 | * } 30 | * 31 | * } 32 | */ 33 | export class WorkerManager { 34 | 35 | /** 36 | * List of workers with details to created new worker instances. Passed into `WorkerModule.forWorkers()` 37 | */ 38 | private workerDefinitions: WorkerDefinition[]; 39 | 40 | /** 41 | * Creates a new `WorkerManager` and called from `WorkerModule.forWorkers()` where the angular provider is created 42 | * @param workerDefintions List of workers with details to create new worker instances. Passed into `WorkerModule.forWorkers()` 43 | */ 44 | constructor(workerDefintions: WorkerDefinition[]) { 45 | this.workerDefinitions = workerDefintions ? workerDefintions : []; 46 | } 47 | 48 | /** 49 | * Factory function that creates a new `WorkerClient`. The worker definitions must first be registered when importing the `WorkerModule.forWorkers()` module, otherwise 50 | * it will throw an error 51 | * @param workerType the worker class 52 | * @param runInApp whether the execution of the worker code is run in the application's "thread". Defaults to run in the worker script 53 | * @example 54 | * // module --- 55 | * imports: [ 56 | * WorkerModule.forWorkers([ 57 | * {worker: AppWorker, initFn: () => new Worker('./app.worker.ts', {type: 'module'})}, 58 | * ]) 59 | * ] 60 | * 61 | * // usage --- 62 | * export class AppComponent implements OnInit { 63 | * 64 | * constructor(private workerManager: WorkerManager) {} 65 | * 66 | * ngOnInit() { 67 | * let client: WorkerClient ; 68 | * if(workerManager.isBrowserCompatible) { 69 | * client = this.workerManager.createClient(AppWorker); 70 | * } else { 71 | * // only if worker execution does not have UI blocking code else implement other behaviour 72 | * client = this.workerManager.createClient(AppWorker, true); 73 | * } 74 | * } 75 | * 76 | * } 77 | */ 78 | createClient(workerType: WebWorkerType, runInApp: boolean = false): WorkerClient { 79 | const definition = this.workerDefinitions.filter(p => p.worker === workerType)[0]; 80 | if (definition) { 81 | return new WorkerClient(definition, runInApp); 82 | } else { 83 | throw new Error('WorkerManager: all web workers must be registered in the forWorkers function of the WorkerModule'); 84 | } 85 | } 86 | 87 | /** 88 | * Whether the browser supports web workers 89 | */ 90 | get isBrowserCompatible(): boolean { 91 | return typeof Worker !== 'undefined'; 92 | } 93 | 94 | 95 | 96 | } 97 | -------------------------------------------------------------------------------- /common/src/lib/annotations.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Worker annotation constants for decorators 4 | */ 5 | export enum WorkerAnnotations { 6 | Annotation = '__worker_annotations__', 7 | Config = '__worker_config__', 8 | Callables = 'callables', 9 | Observables = 'observables', 10 | Accessables = 'accessables', 11 | ShallowTransferArgs = 'shallowTransferArgs', 12 | IsWorker = 'isWorker', 13 | Factory = 'workerFactory' 14 | } 15 | 16 | /** 17 | * Configuration options attached to a worker instance describing if the instance is a client or not 18 | */ 19 | export interface WorkerConfig { 20 | /** 21 | * Whether worker is client instance or not, determined by whether it is created from a `WorkerClient` or the `bootstrapWorker()` function 22 | */ 23 | isClient: boolean; 24 | /** 25 | * A secret that is attached to the client instance of a worker which must be returned when a `WorkerClient` calls any methods/properties of the worker 26 | */ 27 | clientSecret?: string; 28 | } 29 | 30 | /** 31 | * Metadata attached to a worker's prototype for any properties decorated with `@Accessable()`. Contains details that describes how a `WorkerClient` can access the property 32 | */ 33 | export interface AccessableMetaData { 34 | /** 35 | * Name of the decorated property 36 | */ 37 | name: string; 38 | /** 39 | * Prototype of the decorated property's design type that is obtained using reflection 40 | */ 41 | type: Function; 42 | /** 43 | * Determines whether the decorated worker property can be retrieved by a `WorkerClient`. Set as an optional parameter in the `@Accessable()` decorator 44 | * @defaultvalue true 45 | */ 46 | get: boolean; 47 | /** 48 | * Determines whether the decorated worker property can be set from a `WorkerClient`. Set as an optional parameter in the `@Accessable()` decorator 49 | * @defaultvalue true 50 | */ 51 | set: boolean; 52 | /** 53 | * Whether the decoratored property's prototype is transfered after it has been serialized and unserialized during communication between a worker and a client. Set as an optional parameter in the `@Accessable()` decorator 54 | * @defaultvalue false 55 | * @Experimental has limitations 56 | */ 57 | shallowTransfer: boolean; 58 | } 59 | 60 | /** 61 | * Metadata attached to a worker's prototype for any methods decorated with `@Callable()`. Contains details that allows the method to be called from a `WorkerClient` 62 | */ 63 | export interface CallableMetaData { 64 | /** 65 | * Name of the decorated property 66 | */ 67 | name: string; 68 | /** 69 | * Prototype of the decorated method's return type that is obtained using reflection 70 | */ 71 | returnType: Function; 72 | /** 73 | * Whether the returned value's prototype is transfered after it has been serialized and unserialized when it is brought back to a client. Set as an optional parameter in the `@Callable()` decorator 74 | * @defaultvalue false 75 | * @Experimental has limitations (cannot be used with async functions) 76 | */ 77 | shallowTransfer: boolean; 78 | } 79 | 80 | /** 81 | * Metadata attached to a worker's prototype for any RxJS Subject properties that are decorated with `@Subscribable()`. Allows a `WorkerClient` to 82 | * subscribe to and/or create observables from the subject within the worker 83 | */ 84 | export interface SubscribableMetaData { 85 | /** 86 | * Name of the decorated property 87 | */ 88 | name: string; 89 | /** 90 | * Prototype of the decorated property's design type 91 | */ 92 | type: Function; 93 | } 94 | 95 | /** 96 | * Metadata attached to a worker's prototype for method arguments that are decorated with `@ShallowTransfer()`. 97 | * Contains details that allows the argument's prototype to be transfered after it has been serialized and unserialized when sent from a client to be passed as an argument of a worker function. 98 | * @Experimental has limitations 99 | */ 100 | export interface ShallowTransferParamMetaData { 101 | /** 102 | * Name of the decorated property 103 | */ 104 | name: string; 105 | /** 106 | * Prototype of the decorated argument's design type 107 | */ 108 | type: Function; 109 | /** 110 | * Index of the argument in the functions call signiture 111 | */ 112 | argIndex: number; 113 | } 114 | 115 | 116 | -------------------------------------------------------------------------------- /schematics/src/index.ts: -------------------------------------------------------------------------------- 1 | import { JsonParseMode, dirname, join, normalize, parseJsonAst, strings } from '@angular-devkit/core'; 2 | import { 3 | Rule, SchematicContext, SchematicsException, Tree, 4 | apply, applyTemplates, chain, mergeWith, move, noop, url, 5 | } from '@angular-devkit/schematics'; 6 | import { findPropertyInAstObject } from '@schematics/angular/utility/json-utils'; 7 | import { parseName } from '@schematics/angular/utility/parse-name'; 8 | import { relativePathToWorkspaceRoot } from '@schematics/angular/utility/paths'; 9 | import { buildDefaultPath, getWorkspace, updateWorkspace } from '@schematics/angular/utility/workspace'; 10 | import { BrowserBuilderOptions, LintBuilderOptions } from '@schematics/angular/utility/workspace-models'; 11 | import { WebWorkerSchema } from './schema'; 12 | 13 | // modified from '@schematics/angular/web-worker' 14 | export function addConfig(options: WebWorkerSchema, root: string): Rule { 15 | return (tree: Tree, context: SchematicContext) => { 16 | return mergeWith( 17 | apply(url('./files/ts-config'), [ 18 | applyTemplates({ 19 | ...options, 20 | relativePathToWorkspaceRoot: relativePathToWorkspaceRoot(root), 21 | }), 22 | move(root), 23 | ]), 24 | ); 25 | }; 26 | } 27 | 28 | export function checkForTsConfigWorkerExclusion(tree: Tree, tsConfigPath: string): void { 29 | 30 | const isInSrc = dirname(normalize(tsConfigPath)).endsWith('src'); 31 | const workerGlob = `${isInSrc ? '' : 'src/'}**/*.worker.ts`; 32 | 33 | const buffer = tree.read(tsConfigPath); 34 | 35 | if (buffer) { 36 | const tsCfgAst = parseJsonAst(buffer.toString(), JsonParseMode.Loose); 37 | if (tsCfgAst.kind != 'object') { 38 | throw new SchematicsException('Invalid tsconfig. Was expecting an object'); 39 | } 40 | const filesAstNode = findPropertyInAstObject(tsCfgAst, 'exclude'); 41 | if (filesAstNode && filesAstNode.kind != 'array') { 42 | throw new SchematicsException('Invalid tsconfig "exclude" property; expected an array.'); 43 | } 44 | 45 | if (filesAstNode) { 46 | if ((filesAstNode.value).includes(workerGlob)) { 47 | throw new SchematicsException(`Invalid tsconfig, cannot exclude ${workerGlob} in ${tsConfigPath}`); 48 | } 49 | } 50 | } 51 | } 52 | 53 | // code adapted from '@schematics/angular/web-worker' 54 | export default function (options: WebWorkerSchema): Rule { 55 | return async (tree: Tree) => { 56 | const workspace = await getWorkspace(tree); 57 | 58 | if (!options.project) { 59 | throw new SchematicsException('Option "project" is required.'); 60 | } 61 | 62 | if (!options.target) { 63 | throw new SchematicsException('Option "target" is required.'); 64 | } 65 | 66 | const project = workspace.projects.get(options.project); 67 | if (!project) { 68 | throw new SchematicsException(`Invalid project name (${options.project})`); 69 | } 70 | const projectType = project.extensions['projectType']; 71 | if (projectType !== 'application') { 72 | throw new SchematicsException(`Web Worker requires a project type of "application".`); 73 | } 74 | 75 | const projectTarget = project.targets.get(options.target); 76 | if (!projectTarget) { 77 | throw new Error(`Target is not defined for this project.`); 78 | } 79 | const projectTargetOptions = (projectTarget.options || {}) as unknown as BrowserBuilderOptions; 80 | 81 | if (options.path === undefined) { 82 | options.path = buildDefaultPath(project); 83 | } 84 | 85 | const parsedPath = parseName(options.path, options.name); 86 | options.name = parsedPath.name; 87 | options.path = parsedPath.path; 88 | const root = project.root || ''; 89 | const needWebWorkerConfig = !projectTargetOptions.webWorkerTsConfig; 90 | 91 | if (needWebWorkerConfig) { 92 | const workerConfigPath = join(normalize(root), 'tsconfig.worker.json'); 93 | projectTargetOptions.webWorkerTsConfig = workerConfigPath; 94 | const lintTarget = project.targets.get('lint'); 95 | if (lintTarget) { 96 | const lintOptions = (lintTarget.options || {}) as unknown as LintBuilderOptions; 97 | lintOptions.tsConfig = (lintOptions.tsConfig || []).concat(workerConfigPath); 98 | } 99 | } 100 | 101 | checkForTsConfigWorkerExclusion(tree, projectTargetOptions.tsConfig) 102 | 103 | const templateSource = apply(url('./files/worker'), [ 104 | applyTemplates({ ...options, ...strings }), 105 | move(parsedPath.path), 106 | ]); 107 | 108 | 109 | return chain([ 110 | needWebWorkerConfig ? addConfig(options, root) : noop(), 111 | needWebWorkerConfig ? updateWorkspace(workspace) : noop(), 112 | mergeWith(templateSource) 113 | ]); 114 | 115 | } 116 | } -------------------------------------------------------------------------------- /angular/src/lib/worker-client-types.ts: -------------------------------------------------------------------------------- 1 | import { Subject, Subscription, Observable } from 'rxjs'; 2 | import { WorkerResponseEvent, SecretResult, WorkerEvents, WorkerAccessableBody, WorkerSubscribableBody, WorkerCallableBody } from 'angular-web-worker/common'; 3 | 4 | /** 5 | * A dictionary of client observables that have been created to listen to events trigger by RxJS subjects in the worker. 6 | * The dictionary keys map the the messages that are sent from the worker to a particular observable in the client. 7 | */ 8 | export interface WorkerClientObservablesDict { 9 | [key: string]: WorkerClientObservableRef; 10 | } 11 | 12 | /** 13 | * A definition of a client observable that listens to events triggered by RxJS subjects in the worker and then triggers events in the browser 14 | * which depends on which `WorkerClient` method was used to create the listener 15 | */ 16 | export interface WorkerClientObservableRef { 17 | /** 18 | * The event that is triggered in the client when a observable message is recieved from the worker. 19 | * This will either execute a subscription or trigger an observable depending on whether the event listener was registered with the 20 | * `WorkerClient.subscribe()` or `WorkerClient.observe()` method. 21 | */ 22 | subject: Subject; 23 | /** 24 | * A subscription to the `WorkerClientObservableRef.subject` which is created and returned by the `WorkerClient.subscribe()` method. 25 | */ 26 | subscription: Subscription; 27 | /** 28 | * An observable from the `WorkerClientObservableRef.subject` which is created and returned by the `WorkerClient.observe()` method. 29 | */ 30 | observable: Observable; 31 | /** 32 | * The name of the worker's RxJS subject property that the client is listening to 33 | */ 34 | propertyName: string; 35 | } 36 | 37 | /** 38 | * Configurable options that defines how a `WorkerClient` sends a request to, and handles the response from a `WorkerController` through the `WorkerClient.sendRequest()` method 39 | */ 40 | export interface WorkerClientRequestOpts { 41 | /** 42 | * Whether the request is triggered by the init event and therefore not requiring the client's connected property to be true 43 | */ 44 | isConnect?: boolean; 45 | /** 46 | * The worker property to which the request relates. Can be provided as a string, or a lamda function which is used in the `WorkerClient`'s APIs 47 | */ 48 | workerProperty?: ((worker: T) => ReturnType) | string; 49 | /** 50 | * The error message when the `WorkerClient.sendRequest()` method is rejected from the targeted worker property/method not returning the correct `SecretResult` 51 | * when called upon by the client 52 | */ 53 | secretError: string; 54 | /** 55 | * Any conditions that need to be met, in addition to the correct `SecretResult`, before a request can be made to the worker 56 | */ 57 | additionalConditions?: { if: (secretResult?: SecretResult) => boolean, reject: (secretResult?: SecretResult) => any }[]; 58 | /** 59 | * A placeholder to perform unique work in the more generic `WorkerClient.sendRequest()` method. This occurs immediately before the client sends the request to 60 | * the worker and after the `SecretKey` is validated, along with any `additionalConditions` if the option was specified. The value returned 61 | * by this function is available for use through the `additionalContext` arguments in the `body`, `resolve` and `beforeReject` options' functions 62 | */ 63 | beforeRequest?: (secretResult?: SecretResult) => any; 64 | /** 65 | * Must return the `WorkerRequestEvent.body` that will be sent to the worker. The structure is determined by the `WorkerClientRequestOpts`'s 66 | * `EventType` type argument 67 | * @param secretResult the `SecretResult` that is returned when the client called upon the targeted worker property or method 68 | * @param additionalContext if the `beforeRequest` option is provided it is the returned result of that function 69 | * otherwise it will be undefined 70 | */ 71 | body?: (secretResult?: SecretResult, additionalContext?: any) => EventType extends WorkerEvents.Callable ? WorkerCallableBody 72 | : EventType extends WorkerEvents.Accessable ? WorkerAccessableBody 73 | : EventType extends WorkerEvents.Observable ? WorkerSubscribableBody : null; 74 | /** 75 | * Function that returns the value that is resolved by the `WorkerClient.sendRequest()` method. Only occurs if a successful request has been made to, and a response has been recieved from the worker 76 | * @param response the `WorkerResponseEvent` that was returned by the worker 77 | * @param secretResult the `SecretResult` that was returned when the client called upon the targeted worker property or method 78 | * @param additionalContext if the `beforeRequest` option is provided it is the returned result of that function 79 | * otherwise it will be undefined 80 | */ 81 | resolve?: (response?: WorkerResponseEvent, secretResult?: SecretResult, additionalContext?: any) => any; 82 | /** 83 | * A placeholder to perform unique work in the more generic `WorkerClient.sendRequest()` method. This occurs immediately before the request is rejected due to an error 84 | * being caught 85 | * @param response the `WorkerResponseEvent` that was returned by the worker 86 | * @param secretResult the `SecretResult` that was returned when the client called upon the targeted worker property or method 87 | * @param additionalContext if the `beforeRequest` option is provided it is the returned result of that function 88 | */ 89 | beforeReject?: (response?: WorkerResponseEvent, secretResult?: SecretResult, additionalContext?: any) => void; 90 | } 91 | 92 | 93 | -------------------------------------------------------------------------------- /worker/src/lib/web-worker-decorator.ts: -------------------------------------------------------------------------------- 1 | import { WorkerUtils, WorkerConfig, WorkerAnnotations, AccessableMetaData, SecretResult, WorkerEvents, SubscribableMetaData } from 'angular-web-worker/common'; 2 | 3 | /* 4 | * Collection of factory functions for the factory as attached to a single object which allows for testing of imported function 5 | */ 6 | export interface WorkerFactoryFunctionsDict { 7 | /* 8 | * Attaches a worker configuration to an instance of a worker class 9 | * @param instance instance of the worker class 10 | * @param config configuration 11 | */ 12 | setWorkerConfig: (instance: any, config: WorkerConfig) => void; 13 | /* 14 | * Adds a get wrapper to all properties decorated with `@Accessable()` which returns a `SecretResult` if the class instance is a client, otherwise it will use the default behaviour 15 | * @param instance instance of the worker class 16 | */ 17 | configureAccessables: (instance: any) => void; 18 | /** 19 | * Adds a get wrapper to all properties decorated with `@Subscribable()` which returns a `SecretResult` if the class instance is a client, otherwise it will use the default behaviour 20 | * @param instance instance of the worker class 21 | */ 22 | configureSubscribables: (instance: any) => void; 23 | } 24 | 25 | export const WorkerFactoryFunctions: WorkerFactoryFunctionsDict = { 26 | /* 27 | * Attaches a worker configuration to an instance of a worker class 28 | * @param instance instance of the worker class 29 | * @param config configuration 30 | */ 31 | setWorkerConfig: (instance: any, config: WorkerConfig) => { 32 | Object.defineProperty(instance, WorkerAnnotations.Config, { 33 | get: function () { 34 | return config; 35 | }, 36 | enumerable: true, 37 | configurable: true 38 | }); 39 | }, 40 | 41 | configureAccessables: (instance: any) => { 42 | const accessables: AccessableMetaData[] = WorkerUtils.getAnnotation(instance.__proto__.constructor, WorkerAnnotations.Accessables, []); 43 | 44 | if (accessables) { 45 | accessables.forEach((item) => { 46 | let _val = instance[item.name]; 47 | const getter = function () { 48 | const config: WorkerConfig = this.__worker_config__; 49 | if (config) { 50 | if (config.isClient) { 51 | const secret: SecretResult = { 52 | clientSecret: config.clientSecret, 53 | type: WorkerEvents.Accessable, 54 | propertyName: item.name, 55 | body: { 56 | get: item.get, 57 | set: item.set 58 | } 59 | }; 60 | return secret; 61 | } else { 62 | return _val; 63 | } 64 | } else { 65 | return _val; 66 | } 67 | }; 68 | 69 | const setter = newVal => { 70 | _val = newVal; 71 | }; 72 | 73 | delete instance[item.name]; 74 | Object.defineProperty(instance, item.name, { 75 | get: getter, 76 | set: setter, 77 | enumerable: true, 78 | configurable: true 79 | }); 80 | 81 | }); 82 | } 83 | 84 | }, 85 | 86 | configureSubscribables: (instance: any) => { 87 | 88 | const observables = WorkerUtils.getAnnotation(instance.__proto__.constructor, WorkerAnnotations.Observables, []); 89 | 90 | if (observables) { 91 | observables.forEach((item) => { 92 | let _val = instance[item.name]; 93 | 94 | const getter = function () { 95 | const config: WorkerConfig = this.__worker_config__; 96 | if (config) { 97 | if (config.isClient) { 98 | const secret: SecretResult = { 99 | clientSecret: config.clientSecret, 100 | type: WorkerEvents.Observable, 101 | propertyName: item.name, 102 | body: null 103 | }; 104 | return secret; 105 | } else { 106 | return _val; 107 | } 108 | } else { 109 | return _val; 110 | } 111 | }; 112 | 113 | const setter = newVal => { 114 | _val = newVal; 115 | }; 116 | 117 | delete instance[item.name]; 118 | Object.defineProperty(instance, item.name, { 119 | get: getter, 120 | set: setter, 121 | enumerable: true, 122 | configurable: true 123 | }); 124 | 125 | }); 126 | } 127 | 128 | } 129 | }; 130 | 131 | /** 132 | * Class decorator allowing the class to be bootstrapped into a web worker script, and allowing communication with a `WorkerClient` 133 | */ 134 | export function AngularWebWorker() { 135 | 136 | return function (target: any) { 137 | WorkerUtils.setAnnotation(target, WorkerAnnotations.IsWorker, true); 138 | WorkerUtils.setAnnotation(target, WorkerAnnotations.Factory, function create(config: WorkerConfig) { 139 | const instance = new target(); 140 | WorkerFactoryFunctions.setWorkerConfig(instance, config); 141 | WorkerFactoryFunctions.configureAccessables(instance); 142 | WorkerFactoryFunctions.configureSubscribables(instance); 143 | return instance; 144 | }); 145 | 146 | }; 147 | 148 | } 149 | 150 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /worker/test/web-worker-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { AngularWebWorker, Accessable, WorkerFactoryFunctions } from '../src/public-api'; 2 | import { WorkerAnnotations, WorkerConfig, WorkerEvents, SecretResult } from '../../common/src/public-api'; 3 | import { Subscribable } from '../src/public-api'; 4 | import { Subject } from 'rxjs'; 5 | 6 | @AngularWebWorker() 7 | class WorkerTestClass { 8 | name: string; 9 | constructor() { 10 | this.name = 'Peter'; 11 | } 12 | } 13 | 14 | describe('@AngularWebWorker(): [angular-web-worker]', () => { 15 | 16 | 17 | it('Should attach metadata', () => { 18 | expect(WorkerTestClass[WorkerAnnotations.Annotation][WorkerAnnotations.IsWorker]).toEqual(true); 19 | }); 20 | 21 | it('Should attach metadata with a factory function', () => { 22 | expect(typeof WorkerTestClass[WorkerAnnotations.Annotation][WorkerAnnotations.Factory]).toEqual('function'); 23 | }); 24 | 25 | describe('Worker factory', () => { 26 | 27 | const config: WorkerConfig = { 28 | isClient: false 29 | }; 30 | 31 | it('Should return a new class instance', () => { 32 | const spy = spyOn(WorkerFactoryFunctions, 'setWorkerConfig'); 33 | expect(WorkerTestClass[WorkerAnnotations.Annotation][WorkerAnnotations.Factory](config)).toEqual(new WorkerTestClass()); 34 | }); 35 | 36 | it('Should call setWorkerConfig with config and new instance', () => { 37 | const spy = spyOn(WorkerFactoryFunctions, 'setWorkerConfig'); 38 | WorkerTestClass[WorkerAnnotations.Annotation][WorkerAnnotations.Factory](config); 39 | expect(spy).toHaveBeenCalledWith(new WorkerTestClass(), config); 40 | }); 41 | 42 | it('Should call WorkerFactoryFunctions.configureAccessables', () => { 43 | const spy = spyOn(WorkerFactoryFunctions, 'configureAccessables'); 44 | WorkerTestClass[WorkerAnnotations.Annotation][WorkerAnnotations.Factory](config); 45 | expect(spy).toHaveBeenCalled(); 46 | }); 47 | 48 | it('Should call WorkerFactoryFunctions.configureSubscribables', () => { 49 | const spy = spyOn(WorkerFactoryFunctions, 'configureSubscribables'); 50 | WorkerTestClass[WorkerAnnotations.Annotation][WorkerAnnotations.Factory](config); 51 | expect(spy).toHaveBeenCalled(); 52 | }); 53 | 54 | describe('WorkerFactoryFunctions.configureAccessables()', () => { 55 | 56 | class AccessableTestClass { 57 | @Accessable() 58 | public property: string; 59 | public property2: string; 60 | } 61 | 62 | let clientInstance: AccessableTestClass; 63 | let workerInstance: AccessableTestClass; 64 | const property1Value = 'value1'; 65 | const property2Value = 'value2'; 66 | const clientSecretKey = 'my-secret'; 67 | 68 | beforeEach(() => { 69 | clientInstance = new AccessableTestClass(); 70 | clientInstance[WorkerAnnotations.Config] = { 71 | isClient: true, 72 | clientSecret: clientSecretKey 73 | }; 74 | clientInstance.property = property1Value; 75 | clientInstance.property2 = property2Value; 76 | WorkerFactoryFunctions.configureAccessables(clientInstance); 77 | 78 | workerInstance = new AccessableTestClass(); 79 | workerInstance[WorkerAnnotations.Config] = { 80 | isClient: false, 81 | }; 82 | workerInstance.property = property1Value; 83 | workerInstance.property2 = property2Value; 84 | WorkerFactoryFunctions.configureAccessables(workerInstance); 85 | }); 86 | 87 | it('For client instances, it should replace the getter of decorated properties to return a secret', () => { 88 | const secretResult: SecretResult = { 89 | clientSecret: clientSecretKey, 90 | propertyName: 'property', 91 | type: WorkerEvents.Accessable, 92 | body: { 93 | get: true, 94 | set: true, 95 | } 96 | }; 97 | expect((clientInstance.property)).toEqual(secretResult); 98 | }); 99 | 100 | it('For client instances, it should not replace the getter of undecorated properties', () => { 101 | expect(clientInstance.property2).toEqual(property2Value); 102 | }); 103 | 104 | it('For worker instances, it should not replace the getter functionality of any properties', () => { 105 | expect(workerInstance.property).toEqual(property1Value); 106 | expect(workerInstance.property2).toEqual(property2Value); 107 | }); 108 | 109 | it('For instances where no config has been set, it should not replace the getter functionality of any properties', () => { 110 | const instance = new AccessableTestClass(); 111 | instance.property = property1Value; 112 | instance.property2 = property2Value; 113 | expect(instance.property).toEqual(property1Value); 114 | expect(instance.property2).toEqual(property2Value); 115 | }); 116 | 117 | }); 118 | 119 | describe('WorkerFactoryFunctions.configureSubscribables()', () => { 120 | 121 | class SubscribableTestClass { 122 | @Subscribable() 123 | public event: Subject; 124 | public event2: Subject; 125 | } 126 | 127 | let clientInstance: SubscribableTestClass; 128 | let workerInstance: SubscribableTestClass; 129 | const subjectValue = new Subject(); 130 | const clientSecretKey = 'my-secret'; 131 | 132 | beforeEach(() => { 133 | clientInstance = new SubscribableTestClass(); 134 | clientInstance[WorkerAnnotations.Config] = { 135 | isClient: true, 136 | clientSecret: clientSecretKey 137 | }; 138 | clientInstance.event = subjectValue; 139 | clientInstance.event2 = subjectValue; 140 | WorkerFactoryFunctions.configureSubscribables(clientInstance); 141 | 142 | workerInstance = new SubscribableTestClass(); 143 | workerInstance[WorkerAnnotations.Config] = { 144 | isClient: false, 145 | }; 146 | workerInstance.event = subjectValue; 147 | WorkerFactoryFunctions.configureSubscribables(workerInstance); 148 | }); 149 | 150 | it('For client instances, it should replace the getter of decorated properties to return a secret', () => { 151 | const secretResult: SecretResult = { 152 | clientSecret: clientSecretKey, 153 | propertyName: 'event', 154 | type: WorkerEvents.Observable, 155 | body: null 156 | }; 157 | expect((clientInstance.event)).toEqual(secretResult); 158 | }); 159 | 160 | it('For client instances, it should not replace the getter of undecorated properties', () => { 161 | expect(clientInstance.event2).toEqual(subjectValue); 162 | }); 163 | 164 | it('For worker instances, it should not replace the getter functionality of any properties', () => { 165 | expect(workerInstance.event).toEqual(subjectValue); 166 | expect(workerInstance.event2).toEqual(undefined); 167 | }); 168 | 169 | it('For instances where no config has been set, it should not replace the getter functionality of any properties', () => { 170 | const instance = new SubscribableTestClass(); 171 | instance.event = subjectValue; 172 | expect(instance.event).toEqual(subjectValue); 173 | expect(instance.event2).toEqual(undefined); 174 | }); 175 | 176 | }); 177 | 178 | describe('WorkerFactoryFunctions.setWorkerConfig()', () => { 179 | 180 | it('Should attach config to class intance', () => { 181 | const instance = new WorkerTestClass(); 182 | WorkerFactoryFunctions.setWorkerConfig(instance, config); 183 | expect(instance[WorkerAnnotations.Config]).toEqual(config); 184 | }); 185 | 186 | }); 187 | 188 | }); 189 | 190 | 191 | }); 192 | -------------------------------------------------------------------------------- /common/src/lib/worker-events.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Numeric enum of worker event types that are sent between a `WorkerClient` and a `WorkerController` 4 | */ 5 | export enum WorkerEvents { 6 | /** 7 | * Event type for calling worker methods decorated with `@Callable()`. Triggered in the `WorkerClient.call()` method 8 | */ 9 | Callable = 1, 10 | /** 11 | * Event type for accessing worker properties decorated with `@Accessable()`. Triggered in the `WorkerClient.get()` and `WorkerClient.set()` methods 12 | */ 13 | Accessable = 2, 14 | /** 15 | * Event type for creating and/or removing subscriptions or observables from RxJS subjects within a worker that are decorated with `@Subscribable()`. 16 | * Triggered in the `WorkerClient.subscribe()`, `WorkerClient.observe()` and `WorkerClient.unsubscribe()` 17 | */ 18 | Observable = 3, 19 | /** 20 | * Event type for observables that are triggered within the worker and delivered to a `WorkerClient` which occurs after a client has subscribed to, or observed a worker subject. 21 | * This differs from the other events types as it is one-way communication and therefore is not triggered by a request but rather observables in the worker 22 | */ 23 | ObservableMessage = 4, 24 | /** 25 | * Event type when the worker script is created in the browser which triggers the `onWorkerInit` life-cycle hook if implemented 26 | */ 27 | Init = 5 28 | } 29 | 30 | /** 31 | * A typed event interface for genericly describing the data of the native `MessageEvent` which is sent with `Worker.postMessage` 32 | * @Serialized 33 | */ 34 | export interface WorkerEvent extends MessageEvent { 35 | data: T; 36 | } 37 | 38 | /** 39 | * Event that is sent from a `WorkerClient` to a `WorkerController` containing details to trigger work in the web worker 40 | * @Serialized 41 | */ 42 | export interface WorkerRequestEvent { 43 | /** 44 | * The type worker request which also determines structure of the request's `body` property 45 | * @see WorkerEvents 46 | */ 47 | type: EventType; 48 | /** 49 | * Name of the worker property/method that triggered the request 50 | */ 51 | propertyName: string; 52 | /** 53 | * Secret key that is generated by a `WorkerClient` for each request and returned back in the response by a `WorkerController` 54 | * after the worker has completed the desired task. Allows the worker's response to mapped back to the request. 55 | */ 56 | requestSecret: string; 57 | /** 58 | * Detail of the request that is specific to the request type. The structure is conditional on the request's generic `EventType` 59 | * type argument as well as the request's `type` property 60 | */ 61 | body: EventType extends WorkerEvents.Callable ? WorkerCallableBody : EventType extends WorkerEvents.Accessable ? WorkerAccessableBody 62 | : EventType extends WorkerEvents.Observable ? WorkerSubscribableBody : null; 63 | } 64 | 65 | /** 66 | * The body of a `WorkerRequestEvent` when the type is `WorkerEvents.Accessable` 67 | * @Serialized 68 | */ 69 | export interface WorkerAccessableBody { 70 | /** 71 | * Determines whether the request is intended to get or set the value of a worker's property 72 | */ 73 | isGet: boolean; 74 | /** 75 | * When `isGet` is false, it is serializabe the value to which the worker's property will be set 76 | */ 77 | value?: any; 78 | } 79 | 80 | /** 81 | * The body of a `WorkerRequestEvent` when the type is `WorkerEvents.Callable` 82 | * @Serialized 83 | */ 84 | export interface WorkerCallableBody { 85 | /** 86 | * Array of function arguments to be applied to the when the worker's method is called 87 | */ 88 | arguments: any[]; 89 | } 90 | 91 | /** 92 | * The body of a `WorkerRequestEvent` when the type is `WorkerEvents.Observable`. 93 | * @Serialized 94 | */ 95 | export interface WorkerSubscribableBody { 96 | /** 97 | * Whether the request is intended to unsubscribe or subscribe to an observable 98 | */ 99 | isUnsubscribe: boolean; 100 | /** 101 | * A unique key generated by a `WorkerClient` allowing messages triggered by subscriptions in a `WorkerController` (subscribing to observables in the worker) 102 | * to be mapped to trigger observable events in the client and any consequent subscriptions 103 | */ 104 | subscriptionKey: string; 105 | } 106 | 107 | /** 108 | * Event that is sent from a `WorkerController` to a `WorkerClient` in response to a particular request from the client. 109 | * **NOTE:** Errors are also communicated through this response event as the native `Worker.onerror` does not bubble up to be 110 | * caught in the async functions of the `WorkerClient`. 111 | * @Serialized 112 | */ 113 | export interface WorkerResponseEvent { 114 | /** 115 | * The type worker event. Unlike the `WorkerRequestEvent` this does not affect the structure of the response result 116 | * @see WorkerEvents 117 | */ 118 | type: number; 119 | /** 120 | * Name of the worker property/method that originally triggered the event 121 | */ 122 | propertyName: string; 123 | /** 124 | * Secret key that is generated by a `WorkerClient` for each request and returned back in the response by a `WorkerController` 125 | * after the worker has completed the desired task. Allows the worker's response to mapped back to the request. 126 | */ 127 | requestSecret: string; 128 | /** 129 | * Whether the response arose from an error that was caught in the worker 130 | */ 131 | isError: boolean; 132 | /** 133 | * The result of the response when not triggered from an error. 134 | * @Serialized Functions will not be copied and circular references will cause errors 135 | */ 136 | result: T; 137 | /** 138 | * The error message if the response was triggered by an error 139 | */ 140 | error?: any; 141 | } 142 | 143 | /** 144 | * Describes the `WorkerResponseEvent.result` when any observables in the worker trigger messages that must be sent to a client. **Note:** this 145 | * differs from other event responses as it is one-way communication and therefore is not triggered by a request but rather observables in the worker. 146 | * @Serialized 147 | */ 148 | export interface WorkerObservableMessage { 149 | /** 150 | * The type of observable message sent to the client which aligns to RxJS observables being `next`, `onerror` and `complete` 151 | * @see WorkerObservableMessageTypes 152 | */ 153 | type: number; 154 | /** 155 | * A unique key recieved from the client when the client initially subscribed to the observable. 156 | * Allows the message to be mapped to the trigger the correct event when recived by the client 157 | */ 158 | key: string; 159 | /** 160 | * Value communicated by the observable when the event type is `WorkerObservableMessageTypes.Next` 161 | * @Serialized Functions will not be copied and circular references will cause errors 162 | */ 163 | value?: any; 164 | /** 165 | * Error communicated by the observable when the event type is `WorkerObservableMessageTypes.Error` 166 | * @Serialized Functions will not be copied and circular references will cause errors 167 | */ 168 | error?: any; 169 | } 170 | 171 | /** 172 | * The event type when a `WorkerResponseEvent` response is sent to a client after being triggered by an obvservable in the worker 173 | */ 174 | export enum WorkerObservableMessageTypes { 175 | Next = 1, 176 | Error = 2, 177 | Complete = 3 178 | } 179 | 180 | 181 | /** 182 | * A secret that is returned when a `WorkerClient` calls any methods or properties of the client instance of a worker. Allows the client to know if that method or worker has been decorated 183 | * and also contains details that are neccessary to make a `postMessage` request to the `WorkerController` 184 | */ 185 | export interface SecretResult { 186 | /** 187 | * The type of worker secret result which also determines structure of the secret's `body` property 188 | * @see WorkerEvents 189 | */ 190 | type: SecretType; 191 | /** 192 | * A secret key that generated by a `WorkerClient` and is attached the client instance of a worker which needs to be returned when a decorated property or method is called by a `WorkerClient` 193 | */ 194 | clientSecret: string; 195 | /** 196 | * The name of the property or method that has been called 197 | */ 198 | propertyName: string; 199 | /** 200 | * Detail of the secret that is specific to the secret type. The structure is conditional on the secrets generic `SecretType` type argument as well as the secret's `type` property 201 | * @see WorkerEvents 202 | */ 203 | body: SecretType extends WorkerEvents.Callable ? SecretCallableBody : SecretType extends WorkerEvents.Accessable ? SecretAccessableBody : null; 204 | } 205 | 206 | /** 207 | * The body of a `SecretResult` when the type is `WorkerEvents.Callable` 208 | */ 209 | export interface SecretCallableBody { 210 | /** 211 | * The function arguments that where used to call the workers method, which are then sent in a `WorkerRequestEvent` to call the same method in the worker 212 | */ 213 | args: any[]; 214 | } 215 | 216 | /** 217 | * The body of a `SecretResult` when the type is `WorkerEvents.Acessable` 218 | */ 219 | export interface SecretAccessableBody { 220 | /** 221 | * Whether the client can perform a get operation on the decorated property. Set as an optional parameter in the `@Accessable()` decorator 222 | * @defaultvalue true 223 | */ 224 | get: boolean; 225 | /** 226 | * Whether the client can perform a set operation on the decorated property. Set as an optional parameter in the `@Accessable()` decorator 227 | * @defaultvalue true 228 | */ 229 | set: boolean; 230 | } 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | -------------------------------------------------------------------------------- /worker/test/worker-controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { AngularWebWorker, WorkerController, Callable, ShallowTransfer, Accessable, OnWorkerInit, Subscribable } from '../src/public-api'; 2 | import { WorkerRequestEvent, WorkerCallableBody, WorkerSubscribableBody, WorkerObservableMessage, WorkerObservableMessageTypes } from '../../common/src/public-api'; 3 | import { WorkerEvents, WorkerAnnotations, WorkerResponseEvent, WorkerAccessableBody } from '../../common/src/public-api'; 4 | import { Subject, Subscription } from 'rxjs'; 5 | 6 | class TestUser { 7 | 8 | name: string; 9 | age: number; 10 | 11 | constructor(user: Partial) { 12 | this.age = user.age; 13 | this.name = user.name; 14 | } 15 | 16 | birthday() { 17 | this.age++; 18 | } 19 | } 20 | 21 | @AngularWebWorker() 22 | class TestClass implements OnWorkerInit { 23 | 24 | @Accessable() setTestProp: number; 25 | @Accessable() getTestProp: string = 'testvalue'; 26 | @Accessable({ shallowTransfer: true }) transferableTestProp: TestUser; 27 | @Subscribable() subscriptionTest: Subject = new Subject(); 28 | @Subscribable() undefinedSubscriptionTest: Subject; 29 | 30 | constructor() { } 31 | 32 | onWorkerInit() { 33 | } 34 | 35 | @Callable() 36 | argsTestFn(name: string, age: number) { 37 | } 38 | 39 | @Callable() 40 | syncReturnTestFn() { 41 | return 'sync'; 42 | } 43 | 44 | @Callable() 45 | async asyncReturnTestFn() { 46 | return new Promise((resolve, reject) => { 47 | setTimeout(() => { 48 | resolve('async'); 49 | }, 100); 50 | }); 51 | } 52 | 53 | @Callable() 54 | shallowTransferTestFn(baseAge: number, @ShallowTransfer() user: TestUser) { 55 | user.birthday(); 56 | return baseAge + user.age; 57 | } 58 | 59 | 60 | @Callable() 61 | errorTestFn() { 62 | throw new Error('error'); 63 | } 64 | 65 | } 66 | 67 | describe('WorkerController: [angular-web-worker]', () => { 68 | 69 | function createRequest(type: T, propertyName?: string, body?: any): WorkerRequestEvent { 70 | return >{ 71 | type: type, 72 | body: body ? JSON.parse(JSON.stringify(body)) : null, 73 | propertyName: propertyName, 74 | requestSecret: 'secret' 75 | }; 76 | } 77 | 78 | function createResponse(request: WorkerRequestEvent, result?: any): WorkerResponseEvent { 79 | return >{ 80 | type: request.type, 81 | result: result, 82 | isError: false, 83 | propertyName: request.propertyName, 84 | requestSecret: 'secret' 85 | }; 86 | } 87 | 88 | function privateWorker(workerController: WorkerController): TestClass { 89 | return workerController['worker']; 90 | } 91 | 92 | function privateSubscriptionsDict(workerController: WorkerController): { [id: string]: Subscription } { 93 | return workerController['subscriptions']; 94 | } 95 | 96 | let controller: WorkerController; 97 | 98 | beforeEach(() => { 99 | controller = new WorkerController(TestClass, window); 100 | }); 101 | 102 | it(`Should call the worker factory annotation to create a new worker instance with a non-client config`, () => { 103 | const spy = spyOn(TestClass[WorkerAnnotations.Annotation], WorkerAnnotations.Factory).and.callThrough(); 104 | controller = new WorkerController(TestClass, window); 105 | expect(spy).toHaveBeenCalledWith({ isClient: false }); 106 | }); 107 | 108 | it(`Should call the handleInit method when a init client request is recieved through onmessage`, () => { 109 | const spy = spyOn(controller, 'handleInit'); 110 | const initRequest = createRequest(WorkerEvents.Init); 111 | window.onmessage(new MessageEvent('mock-event', { 112 | data: initRequest 113 | })); 114 | expect(spy).toHaveBeenCalledWith(initRequest); 115 | }); 116 | 117 | it(`Should call the OnWorkerInit hook if implemented`, () => { 118 | const spy = spyOn(privateWorker(controller), 'onWorkerInit'); 119 | controller.handleInit(createRequest(WorkerEvents.Init)); 120 | expect(spy).toHaveBeenCalled(); 121 | }); 122 | 123 | describe('Callables', () => { 124 | 125 | it(`Should call the handleCallable method when a callable client request is recieved through onmessage`, () => { 126 | const spy = spyOn(controller, 'handleCallable'); 127 | const callableRequest = createRequest(WorkerEvents.Callable); 128 | window.onmessage(new MessageEvent('mock-event', { 129 | data: callableRequest 130 | })); 131 | expect(spy).toHaveBeenCalledWith(callableRequest); 132 | }); 133 | 134 | it(`Should call the correct worker method with the arguments from the request`, async () => { 135 | const spy = spyOn(privateWorker(controller), 'argsTestFn').and.callThrough(); 136 | await controller.handleCallable(createRequest(WorkerEvents.Callable, 'argsTestFn', { arguments: ['Joe', 2] })); 137 | expect(spy).toHaveBeenCalledWith('Joe', 2); 138 | }); 139 | 140 | it(`Should trigger postMessage with the return value of a sync function`, async () => { 141 | const spy = spyOn(window, 'postMessage').and.callThrough(); 142 | const req = createRequest(WorkerEvents.Callable, 'syncReturnTestFn', { arguments: [] }); 143 | await controller.handleCallable(req); 144 | expect(spy).toHaveBeenCalledWith(createResponse(req, 'sync')); 145 | }); 146 | 147 | it(`Should trigger postMessage with the return value of an async function`, async () => { 148 | const spy = spyOn(controller, 'postMessage').and.callThrough(); 149 | const req = createRequest(WorkerEvents.Callable, 'asyncReturnTestFn', { arguments: [] }); 150 | await controller.handleCallable(req); 151 | expect(spy).toHaveBeenCalledWith(createResponse(req, 'async')); 152 | }); 153 | 154 | it(`Should transfer the object prototypes for args decorated with a @ShallowTransfer()`, async () => { 155 | const spy = spyOn(controller, 'postMessage').and.callThrough(); 156 | const user = new TestUser({ name: 'joe', age: 20 }); 157 | const req = createRequest(WorkerEvents.Callable, 'shallowTransferTestFn', { arguments: [20, user] }); 158 | await controller.handleCallable(req); 159 | expect(spy).toHaveBeenCalledWith(createResponse(req, 41)); 160 | }); 161 | 162 | it(`Should catch errors and return as a WorkerReponseEvent through postMessage`, async () => { 163 | const spy = spyOn(controller, 'postMessage').and.callThrough(); 164 | const req = createRequest(WorkerEvents.Callable, 'errorTestFn', { arguments: [] }); 165 | await controller.handleCallable(req); 166 | const callInfo = spy.calls.mostRecent(); 167 | expect((callInfo.args[0]).isError).toBe(true); 168 | }); 169 | 170 | }); 171 | 172 | describe('Accessables', () => { 173 | 174 | it(`Should call the handleAccessable method when a accessable client request is recieved through onmessage`, () => { 175 | const spy = spyOn(controller, 'handleAccessable'); 176 | const accessableRequest = createRequest(WorkerEvents.Accessable); 177 | window.onmessage(new MessageEvent('mock-event', { 178 | data: accessableRequest 179 | })); 180 | expect(spy).toHaveBeenCalledWith(accessableRequest); 181 | }); 182 | 183 | it('Should set the value of the variable in the worker', () => { 184 | controller.handleAccessable(createRequest(WorkerEvents.Accessable, 'setTestProp', { isGet: false, value: 12 })); 185 | expect(privateWorker(controller).setTestProp).toEqual(12); 186 | }); 187 | 188 | it('Should set the value and transfer prototype of a value when the shallowTransfer option is true', () => { 189 | controller.handleAccessable(createRequest(WorkerEvents.Accessable, 'transferableTestProp', { isGet: false, value: new TestUser({ name: 'name', age: 20 }) })); 190 | expect(privateWorker(controller).transferableTestProp.birthday).toBeTruthy(); 191 | }); 192 | 193 | it('Should get the value of the variable in the worker and return it through postMessage', () => { 194 | const spy = spyOn(controller, 'postMessage').and.callThrough(); 195 | const req = createRequest(WorkerEvents.Accessable, 'getTestProp', { isGet: true }); 196 | controller.handleAccessable(req); 197 | expect(spy).toHaveBeenCalledWith(createResponse(req, 'testvalue')); 198 | }); 199 | 200 | }); 201 | 202 | describe('Observables', () => { 203 | 204 | it(`Should call the handleSubscription method when a observable client request is recieved through onmessage`, () => { 205 | const spy = spyOn(controller, 'handleSubscription'); 206 | const subscribableReq = createRequest(WorkerEvents.Observable); 207 | window.onmessage(new MessageEvent('mock-event', { 208 | data: subscribableReq 209 | })); 210 | expect(spy).toHaveBeenCalledWith(subscribableReq); 211 | }); 212 | 213 | it(`Should should add a subscription to the targeted event subject and add it to the dictionary`, () => { 214 | spyOn(controller, 'postMessage'); 215 | controller.handleSubscription(createRequest(WorkerEvents.Observable, 'subscriptionTest', { isUnsubscribe: false, subscriptionKey: 'key123' })); 216 | expect(privateWorker(controller).subscriptionTest.observers.length).toEqual(1); 217 | expect(privateSubscriptionsDict(controller)['key123']).toBeTruthy(); 218 | }); 219 | 220 | it(`Should should unsubscribe from the targeted event subject`, () => { 221 | privateSubscriptionsDict(controller)['key456'] = privateWorker(controller).subscriptionTest.subscribe(); 222 | spyOn(controller, 'postMessage'); 223 | controller.handleSubscription(createRequest(WorkerEvents.Observable, 'subscriptionTest', { isUnsubscribe: true, subscriptionKey: 'key456' })); 224 | expect(privateWorker(controller).subscriptionTest.observers.length).toEqual(0); 225 | expect(privateSubscriptionsDict(controller)['key456']).toBeFalsy(); 226 | }); 227 | 228 | it('Should catch the error when subscribing from an undefined event subject and return the error in the form of a WorkerResponseEvent through postMessage', () => { 229 | const spy = spyOn(controller, 'postMessage'); 230 | controller.handleSubscription(createRequest(WorkerEvents.Observable, 'undefinedSubscriptionTest', { isUnsubscribe: false, subscriptionKey: 'key456' })); 231 | expect((spy.calls.mostRecent().args[0]).isError).toEqual(true); 232 | }); 233 | 234 | it('Should post an observable message when the subscribed subject\'s next method is triggered', async () => { 235 | 236 | const postMessageSpy = spyOn(controller, 'postMessage'); 237 | controller.handleSubscription(createRequest(WorkerEvents.Observable, 'subscriptionTest', { isUnsubscribe: false, subscriptionKey: 'key123' })); 238 | expect(postMessageSpy).toHaveBeenCalled(); 239 | 240 | const postSubscriptionSpy = spyOn(controller, 'postSubscriptionMessage'); 241 | privateWorker(controller).subscriptionTest.next('value'); 242 | expect(postSubscriptionSpy).toHaveBeenCalledWith(>{ 243 | type: WorkerEvents.ObservableMessage, 244 | propertyName: 'subscriptionTest', 245 | isError: false, 246 | requestSecret: null, 247 | result: { 248 | key: 'key123', 249 | type: WorkerObservableMessageTypes.Next, 250 | value: 'value' 251 | } 252 | }); 253 | }); 254 | 255 | it('Should post an observable message when the subscribed subject\'s complete method is triggered', async () => { 256 | 257 | const postMessageSpy = spyOn(controller, 'postMessage'); 258 | controller.handleSubscription(createRequest(WorkerEvents.Observable, 'subscriptionTest', { isUnsubscribe: false, subscriptionKey: 'key123' })); 259 | expect(postMessageSpy).toHaveBeenCalled(); 260 | 261 | const postSubscriptionSpy = spyOn(controller, 'postSubscriptionMessage'); 262 | privateWorker(controller).subscriptionTest.complete(); 263 | expect(postSubscriptionSpy).toHaveBeenCalledWith(>{ 264 | type: WorkerEvents.ObservableMessage, 265 | propertyName: 'subscriptionTest', 266 | isError: false, 267 | requestSecret: null, 268 | result: { 269 | key: 'key123', 270 | type: WorkerObservableMessageTypes.Complete, 271 | } 272 | }); 273 | }); 274 | 275 | it('Should post an observable message when the subscribed subject\'s error is fired', async () => { 276 | 277 | const postMessageSpy = spyOn(controller, 'postMessage'); 278 | controller.handleSubscription(createRequest(WorkerEvents.Observable, 'subscriptionTest', { isUnsubscribe: false, subscriptionKey: 'key123' })); 279 | expect(postMessageSpy).toHaveBeenCalled(); 280 | 281 | const postSubscriptionSpy = spyOn(controller, 'postSubscriptionMessage'); 282 | privateWorker(controller).subscriptionTest.error(null); 283 | expect(postSubscriptionSpy.calls.mostRecent().args[0].isError).toBe(true); 284 | expect(postSubscriptionSpy.calls.mostRecent().args[0].result.type).toBe(WorkerObservableMessageTypes.Error); 285 | }); 286 | 287 | it('Should unsubscribe from all subscriptions', () => { 288 | const subject1 = new Subject(); 289 | const subject2 = new Subject(); 290 | controller['subscriptions']['1'] = subject1.subscribe(); 291 | controller['subscriptions']['2'] = subject1.subscribe(); 292 | controller['subscriptions']['3'] = subject2.subscribe(); 293 | controller.removeAllSubscriptions(); 294 | expect(subject1.observers.length).toEqual(0); 295 | expect(subject2.observers.length).toEqual(0); 296 | expect(controller['subscriptions']['1']).toBeFalsy(); 297 | expect(controller['subscriptions']['2']).toBeFalsy(); 298 | expect(controller['subscriptions']['3']).toBeFalsy(); 299 | }); 300 | 301 | }); 302 | 303 | }); 304 | -------------------------------------------------------------------------------- /worker/src/lib/worker-controller.ts: -------------------------------------------------------------------------------- 1 | import { Subject, Subscription } from 'rxjs'; 2 | import { 3 | WebWorkerType, WorkerRequestEvent, WorkerEvent, WorkerEvents, WorkerAnnotations, 4 | WorkerUtils, WorkerResponseEvent, ShallowTransferParamMetaData, 5 | AccessableMetaData, WorkerObservableMessage, WorkerObservableMessageTypes, CallableMetaData, WorkerMessageBus 6 | } from 'angular-web-worker/common'; 7 | 8 | /** 9 | * Handles communication to and from a `WorkerClient` and triggers work with the worker class. 10 | */ 11 | export class WorkerController { 12 | 13 | /** 14 | * Instance of the worker class 15 | */ 16 | private worker: any; 17 | /** 18 | * Dictionary of subscriptions to RxJS subjects within the worker 19 | */ 20 | private subscriptions: { [id: string]: Subscription }; 21 | 22 | /** 23 | * Creates a new `WorkerController` 24 | * @param workerClass the worker class, 25 | * @param postMessageFn the worker postMessage function passed into constuctor allowing this to be mocked when running within the app (not the worker script) 26 | * @param onMessageFn the worker onmessage event function passed into constructor allowing this to be mocked when running within the app (not the worker script) 27 | */ 28 | constructor(private workerClass: WebWorkerType, private messageBus: WorkerMessageBus) { 29 | try { 30 | this.worker = WorkerUtils.getAnnotation(workerClass, WorkerAnnotations.Factory)({ 31 | isClient: false 32 | }); 33 | this.subscriptions = {}; 34 | this.registerEvents(); 35 | } catch (e) { } 36 | } 37 | 38 | /** 39 | * Returns instance of worker class 40 | */ 41 | get workerInstance(): T { 42 | return this.worker; 43 | } 44 | 45 | /** 46 | * Creates the event listeners to correctly handle and respond to messages recieved from a `WorkerClient` 47 | */ 48 | private registerEvents() { 49 | this.messageBus.onmessage = (ev: WorkerEvent>) => { 50 | switch (ev.data.type) { 51 | case WorkerEvents.Callable: 52 | this.handleCallable(ev.data); 53 | break; 54 | case WorkerEvents.Accessable: 55 | this.handleAccessable(ev.data); 56 | break; 57 | case WorkerEvents.Observable: 58 | this.handleSubscription(ev.data); 59 | break; 60 | case WorkerEvents.Init: 61 | this.handleInit(ev.data); 62 | break; 63 | } 64 | }; 65 | } 66 | 67 | /** 68 | * A utility function to create a new `WorkerResponseEvent` from the details provided by the `WorkerRequestEvent`, as well as the result to be returned 69 | * @param type The type of worker event 70 | * @param request The request that the response relates to 71 | * @param result data to return with the response 72 | */ 73 | private response( 74 | type: EventType, 75 | request: WorkerRequestEvent, 76 | result: any 77 | ): WorkerResponseEvent { 78 | return { 79 | type: type, 80 | isError: false, 81 | requestSecret: request.requestSecret, 82 | propertyName: request.propertyName, 83 | result: result 84 | }; 85 | } 86 | 87 | /** 88 | * A utility function to create a new error in the form of a `WorkerResponseEvent` from the details provided by the `WorkerRequestEvent`, as well as the error to be returned 89 | * @param type The type of worker event 90 | * @param request The request that the error relates to 91 | * @param result the error to be returned 92 | */ 93 | private error( 94 | type: number, 95 | request: WorkerRequestEvent, 96 | error: any 97 | ): WorkerResponseEvent { 98 | return { 99 | type: type, 100 | isError: true, 101 | requestSecret: request.requestSecret, 102 | propertyName: request.propertyName, 103 | error: JSON.stringify(error, this.replaceErrors), 104 | result: null 105 | }; 106 | } 107 | 108 | /** 109 | * A utility function as the replacer for the `JSON.stringify()` function to make the native browser `Error` class serializable to JSON 110 | */ 111 | private replaceErrors(key: string, value: any) { 112 | if (value instanceof Error) { 113 | const error = {}; 114 | // tslint:disable-next-line: no-shadowed-variable 115 | Object.getOwnPropertyNames(value).forEach(function (key) { 116 | error[key] = value[key]; 117 | }); 118 | return error; 119 | } 120 | return value; 121 | } 122 | 123 | /** 124 | * Handles `WorkerEvents.Init` requests from a client by calling the `onWorkerInit` hook if implemented and only responding once the hook has been completed, regardless of whether it is 125 | * async or not 126 | * @param request request recieved from the `WorkerClient` 127 | */ 128 | async handleInit(request: WorkerRequestEvent) { 129 | if (this.worker['onWorkerInit']) { 130 | try { 131 | const result = this.worker['onWorkerInit'](); 132 | let isPromise = false; 133 | if (result) { 134 | isPromise = result.__proto__.constructor === Promise; 135 | } 136 | if (isPromise) { 137 | result.then(() => { 138 | this.postMessage(this.response(WorkerEvents.Init, request, null)); 139 | }).catch((err: any) => { 140 | this.postMessage(this.error(WorkerEvents.Init, request, err)); 141 | }); 142 | } else { 143 | this.postMessage(this.response(WorkerEvents.Init, request, null)); 144 | } 145 | } catch (e) { 146 | this.postMessage(this.error(WorkerEvents.Init, request, null)); 147 | } 148 | } else { 149 | this.postMessage(this.response(WorkerEvents.Init, request, null)); 150 | } 151 | } 152 | 153 | 154 | /** 155 | * Handles `WorkerEvents.Callable` requests from a client by calling the targeted method and responding with the method's return value 156 | * @param request request recieved from the `WorkerClient` 157 | */ 158 | async handleCallable(request: WorkerRequestEvent) { 159 | let response: WorkerResponseEvent; 160 | try { 161 | request.body.arguments = this.applyShallowTransferToCallableArgs(request, request.body.arguments); 162 | const result = await this.worker[request.propertyName](...request.body.arguments); 163 | 164 | response = this.response(WorkerEvents.Callable, request, result); 165 | } catch (e) { 166 | response = this.error(WorkerEvents.Callable, request, e); 167 | } finally { 168 | this.postMessage(response); 169 | } 170 | 171 | } 172 | 173 | /** 174 | * Transfers the prototype of any function arguments decorated with `@ShallowTransfer()` which have been serialized and recieved from a `WorkerEvents.Callable` request. 175 | * This occurs before the arguments are used to call the worker function. 176 | * @param request request recieved from the `WorkerClient` 177 | * @param args array of function arguments 178 | */ 179 | applyShallowTransferToCallableArgs( 180 | request: WorkerRequestEvent, 181 | args: any[] 182 | ): any[] { 183 | 184 | const metaData = WorkerUtils.getAnnotation(this.workerClass, WorkerAnnotations.ShallowTransferArgs, []); 185 | 186 | if (metaData) { 187 | const shallowTransferMeta = metaData.filter(x => x.name === request.propertyName); 188 | for (let i = 0; i < args.length; i++) { 189 | const meta = shallowTransferMeta.filter(x => x.argIndex === i)[0]; 190 | if (meta) { 191 | if (meta.type && args[i]) { 192 | args[i].__proto__ = meta.type.prototype; 193 | } 194 | } 195 | } 196 | } 197 | 198 | return args; 199 | } 200 | 201 | /** 202 | * Handles `WorkerEvents.Accessable` requests from a client by either setting the target property of the worker or responding with the target property's value 203 | * @param request request recieved from the `WorkerClient` 204 | */ 205 | handleAccessable(request: WorkerRequestEvent) { 206 | let response: WorkerResponseEvent; 207 | try { 208 | const metaData = WorkerUtils.getAnnotation(this.workerClass, 'accessables', []).filter(x => x.name === request.propertyName)[0]; 209 | if (request.body.isGet) { 210 | response = this.response(WorkerEvents.Accessable, request, this.worker[request.propertyName]); 211 | } else { 212 | this.worker[request.propertyName] = request.body.value; 213 | if (metaData.shallowTransfer) { 214 | if (metaData.type && this.worker[request.propertyName]) { 215 | this.worker[request.propertyName].__proto__ = metaData.type.prototype; 216 | } 217 | } 218 | response = this.response(WorkerEvents.Accessable, request, null); 219 | } 220 | } catch (e) { 221 | response = this.error(WorkerEvents.Accessable, request, e); 222 | } finally { 223 | this.postMessage(response); 224 | } 225 | } 226 | 227 | /** 228 | * Handles `WorkerEvents.Subscribable` requests from a client by creating a new subscription to the targeted observable which will send messages to the client each time 229 | * an event is triggered by the observable. The function may also unsubscribe from a subscription depending on the details of the request 230 | * @param request request recieved from the `WorkerClient` 231 | */ 232 | handleSubscription(request: WorkerRequestEvent) { 233 | let response: WorkerResponseEvent; 234 | 235 | if (!request.body.isUnsubscribe) { 236 | try { 237 | this.createSubscription(request); 238 | response = this.response(WorkerEvents.Observable, request, request.body.subscriptionKey); 239 | } catch (e) { 240 | this.removeSubscription(request.body.subscriptionKey); 241 | response = this.error(WorkerEvents.Observable, request, e); 242 | } finally { 243 | this.postMessage(response); 244 | } 245 | } else { 246 | try { 247 | this.removeSubscription(request.body.subscriptionKey); 248 | response = this.response(WorkerEvents.Observable, request, null); 249 | } catch (e) { 250 | response = this.error(WorkerEvents.Observable, request, e); 251 | } finally { 252 | this.postMessage(response); 253 | } 254 | } 255 | } 256 | 257 | /** 258 | * Creates a new subscription to a worker observable and adds it to the `subscriptions` dictionary. The subscriptions will send messages to the client each time 259 | * and event is triggered by the observable 260 | * @param request request recieved from the `WorkerClient` 261 | */ 262 | createSubscription(request: WorkerRequestEvent): void { 263 | 264 | this.removeSubscription(request.body.subscriptionKey); 265 | 266 | this.subscriptions[request.body.subscriptionKey] = (>this.worker[request.propertyName]).subscribe( 267 | (val) => { 268 | const response: WorkerResponseEvent = { 269 | type: WorkerEvents.ObservableMessage, 270 | propertyName: request.propertyName, 271 | isError: false, 272 | requestSecret: null, 273 | result: { 274 | key: request.body.subscriptionKey, 275 | type: WorkerObservableMessageTypes.Next, 276 | value: val 277 | } 278 | }; 279 | this.postSubscriptionMessage(response); 280 | }, err => { 281 | const response: WorkerResponseEvent = { 282 | type: WorkerEvents.ObservableMessage, 283 | propertyName: request.propertyName, 284 | isError: true, 285 | requestSecret: null, 286 | result: { 287 | key: request.body.subscriptionKey, 288 | type: WorkerObservableMessageTypes.Error, 289 | error: JSON.parse(JSON.stringify(err, this.replaceErrors)) 290 | } 291 | }; 292 | this.postSubscriptionMessage(response); 293 | }, () => { 294 | const response: WorkerResponseEvent = { 295 | type: WorkerEvents.ObservableMessage, 296 | propertyName: request.propertyName, 297 | isError: false, 298 | requestSecret: null, 299 | result: { 300 | key: request.body.subscriptionKey, 301 | type: WorkerObservableMessageTypes.Complete, 302 | } 303 | }; 304 | this.postSubscriptionMessage(response); 305 | }); 306 | } 307 | 308 | /** 309 | * Removes a subscription from the `subscriptions` dictionary, unsubscribing before it is deleted 310 | * @param subscriptionKey key in dictionary 311 | */ 312 | removeSubscription(subscriptionKey: string) { 313 | if (this.subscriptions[subscriptionKey]) { 314 | this.subscriptions[subscriptionKey].unsubscribe(); 315 | } 316 | delete this.subscriptions[subscriptionKey]; 317 | } 318 | 319 | /** 320 | * Unsubscribes from all subscriptions 321 | */ 322 | removeAllSubscriptions(): void { 323 | for (const key in this.subscriptions) { 324 | if (this.subscriptions[key]) { 325 | this.subscriptions[key].unsubscribe(); 326 | delete this.subscriptions[key]; 327 | } 328 | } 329 | } 330 | 331 | /** 332 | * A wrapper function around the `postMessage()` method allowing serialization errors to be caught and sent to the client as a `WorkerResponseEvent`. 333 | * Only used when the response is triggered by a request, which is not the case when the event type is `WorkerEvents.ObservableMessage`. 334 | * @param response reponse to send to the client 335 | */ 336 | postMessage( 337 | response: WorkerResponseEvent, 338 | ): void { 339 | try { 340 | this.messageBus.postMessage(response); 341 | } catch (e) { 342 | const errorResponse: WorkerResponseEvent = { 343 | type: response.type, 344 | isError: true, 345 | requestSecret: response.requestSecret, 346 | propertyName: response.propertyName, 347 | error: JSON.parse(JSON.stringify(new Error('Unable to serialize response from worker to client'), this.replaceErrors)), 348 | result: null 349 | }; 350 | this.messageBus.postMessage(errorResponse); 351 | } 352 | } 353 | 354 | /** 355 | * A wrapper function around the `postMessage()` method allowing serialization errors to be caught and sent to the client as a `WorkerResponseEvent`. 356 | * Only used when the response type is `WorkerEvents.ObservableMessage` which requires a different implementation to the `WorkerController.postMessage` wrapper as it 357 | * is one-way communication which is not triggered by a request 358 | */ 359 | postSubscriptionMessage( 360 | response: WorkerResponseEvent, 361 | ): void { 362 | try { 363 | this.messageBus.postMessage(response); 364 | } catch (e) { 365 | const errorResponse: WorkerResponseEvent = { 366 | type: response.type, 367 | isError: true, 368 | requestSecret: response.requestSecret, 369 | propertyName: response.propertyName, 370 | result: { 371 | key: response.result.key, 372 | type: WorkerObservableMessageTypes.Error, 373 | error: JSON.parse(JSON.stringify(new Error('Unable to serialize subsribable response from worker to client'), this.replaceErrors)) 374 | }, 375 | }; 376 | this.messageBus.postMessage(errorResponse); 377 | } 378 | } 379 | 380 | 381 | } 382 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | An Angular library providing an easier way to work web workers in an Angular application. It allows you to interact with a web worker in similiar way to using a simple TypeScript class, enjoying all the features of the TypeScript language as the more messy communication is handled for you. Only supported with Angular 8+ 4 | 5 | # Getting Started 6 | 7 | ### 1. Use an existing, or create a new Angular (v8+) app 8 | 9 | ### 2. Install the library 10 | 11 | > npm i angular-web-worker --save 12 | 13 | ### 3. Use the schematics to create your first worker and configure your app for web workers 14 | 15 | > ng g angular-web-worker:angular-web-worker 16 | 17 | *app.worker.ts* 18 | ```typescript 19 | import { AngularWebWorker, bootstrapWorker, OnWorkerInit } from 'angular-web-worker'; 20 | /// 21 | 22 | @AngularWebWorker() 23 | export class AppWorker implements OnWorkerInit { 24 | 25 | constructor() {} 26 | 27 | onWorkerInit() { 28 | } 29 | 30 | } 31 | bootstrapWorker(AppWorker); 32 | ``` 33 | **IMPORTANT:** 34 | - If you have already used the web-worker schematics from the standard angular-cli v8+ _(ng generate web-worker)_ within your angular app, then you will need to modify the tsconfig.json in the project's root directory by removing "\*\*/\*.worker.ts" from the "exclude" property. If you have not done this the command _ng g angular-web-worker:angular-web-worker_ will prompt you to do so with an error message. 35 | - The worker class cannot directly or indirectly have any import references from the `@angular/core` library as this will cause build errors when the worker is bundled seperately by the [worker-plugin](https://github.com/GoogleChromeLabs/worker-plugin) for webpack. As such, this library has been built to consist of several sub-packages to ensure that the `@angular/core` library is not indirectly imported into a worker class. 36 | 37 | # Usage 38 | 39 | ## The WorkerModule 40 | Once a new worker class has been created, a definition of the worker must be imported into an Angular module through WorkerModule.forWorkers(definitions[]). This plays two roles, it provides an injectable Angular service which is used to create clients that communicate between the Angular app and the worker script. It is also contains the syntax which allows the [worker-plugin](https://github.com/GoogleChromeLabs/worker-plugin) for webpack to detect the worker classes and create seperate bundles that can be loaded as worker scripts in the browser. The [worker-plugin](https://github.com/GoogleChromeLabs/worker-plugin) requires a specific syntax to create these seperate bundles. 41 | 42 | 43 | *app.module.ts* 44 | ```typescript 45 | import { WorkerModule } from 'angular-web-worker/angular'; 46 | import { AppWorker } from './app.worker'; 47 | 48 | @NgModule({ 49 | ... 50 | imports: [ 51 | ..., 52 | // the path in the init function must given as a string (not a variable) and the type must be 'module' 53 | WorkerModule.forWorkers([ 54 | {worker: AppWorker, initFn: () => new Worker('./app.worker.ts', {type: 'module'})}, 55 | ]), 56 | ... 57 | ], 58 | }) 59 | export class AppModule { } 60 | ``` 61 | ## The WorkerClient 62 | Once the defintion/s have been imported into a module the WorkerManager service can be used to create new clients which have the functionality to create, communicate with and terminate workers throughout the angular application. While it seems as if the client calls the worker class directly, the standard postMessage and onmessage communication mechanism is still used under the hood therefore all data is still serialized. Therefore when any class/object is sent to and/or recieved from a worker the data is copied and it's functions/methods will not be transfered. Circular referencing data structures are not serializable. For more on web workers see [here](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) 63 | 64 | *app.component.ts* 65 | ```typescript 66 | import { AppWorker } from './app.worker'; 67 | import { WorkerManager, WorkerClient } from 'angular-web-worker/angular'; 68 | 69 | @Component({ 70 | ... 71 | }) 72 | export class AppComponent implements OnInit { 73 | 74 | private client: WorkerClient; 75 | 76 | constructor(private workerManager: WorkerManager) { } 77 | 78 | ngOnInit() { 79 | if (this.workerManager.isBrowserCompatible) { 80 | this.client = this.workerManager.createClient(AppWorker); 81 | } else { 82 | // if code won't block UI else implement other fallback behaviour 83 | this.client = this.workerManager.createClient(AppWorker, true); 84 | } 85 | } 86 | 87 | } 88 | ``` 89 | ***RUNNING THE WORKER IN THE APPLICATION*** 90 | 91 | The `WorkerManager.createClient()` method has an optional argument to run the worker inside the application, essentially keeping it within the application's "thread". This can be used as fallback behaviour if the browser does not support web workers, however it should be used with caution as intensive work will impact the performance and the user experience. In this case, no data is serialized in order to avoid the "expensive" serialization operations that will hinder performance. However, the worker should be designed with serialization in mind so that the functionality to execute the code in either a seperate worker script or within the app can be used interchangably. For this reason, it is important to use the testing utilities provided by the library which mocks this behaviour to ensure the code works as expected under both circumstances. 92 | 93 | ## Creating a Worker 94 | 95 | The `WorkerClient.connect()` method will create a new worker in the browser and trigger the `OnWorkerInit` lifecycle hook, if it has been implemented. The promise will only be resolved once the `onWorkerInit` method has finished executing. If the `onWorkerInit` method is asynchronous _(returns a promise)_, the connect method will only resolve once that promise has been resolved. 96 | 97 | **OnWorkerInit** 98 | 99 | Any logic or initialization of variables in the constructor of the worker class will cause exceptions to be thrown given that both a client instance and a worker instance of the same class are created behind the scenes. Therefore, the `OnWorkerInit` has been provided as a replacement for the constructor. 100 | 101 | *app.worker.ts* 102 | ```typescript 103 | import { AngularWebWorker, bootstrapWorker, OnWorkerInit } from 'angular-web-worker'; 104 | import { Subject } from 'rxjs'; 105 | /// 106 | 107 | @AngularWebWorker() 108 | export class AppWorker implements OnWorkerInit { 109 | 110 | private event: Subject; 111 | private data: any; 112 | 113 | constructor() {} 114 | 115 | async onWorkerInit() { 116 | // can also be a synchronous function 117 | this.event = new Subject(); 118 | const resp = await fetch('http://example.com'); 119 | this.data = await resp.json(); 120 | } 121 | 122 | } 123 | bootstrapWorker(AppWorker); 124 | ``` 125 | 126 | *app.component.ts* 127 | ```typescript 128 | import { AppWorker } from './app.worker'; 129 | import { WorkerManager, WorkerClient } from 'angular-web-worker/angular'; 130 | 131 | @Component({ 132 | ... 133 | }) 134 | export class AppComponent implements OnInit { 135 | 136 | private client: WorkerClient; 137 | 138 | constructor(private workerManager: WorkerManager) { } 139 | 140 | ngOnInit() { 141 | if (this.workerManager.isBrowserCompatible) { 142 | this.client = this.workerManager.createClient(AppWorker); 143 | } else { 144 | this.client = this.workerManager.createClient(AppWorker, true); 145 | } 146 | } 147 | 148 | async createWorker() { 149 | // can use the Promise.then().catch() syntax if preferred 150 | await this.client.connect(); 151 | } 152 | 153 | } 154 | ``` 155 | 156 | ## Creating Multiple Workers 157 | 158 | Each client is responsable for a single worker, therefore creating multiple instances of the same worker simply involves creating multiple clients 159 | 160 | *app.component.ts* 161 | ```typescript 162 | import { AppWorker } from './app.worker'; 163 | import { WorkerManager, WorkerClient } from 'angular-web-worker/angular'; 164 | 165 | @Component({ 166 | ... 167 | }) 168 | export class AppComponent implements OnInit { 169 | 170 | private client1: WorkerClient; 171 | private client2: WorkerClient; 172 | 173 | constructor(private workerManager: WorkerManager) {} 174 | 175 | ngOnInit() { 176 | if(this.workerManager.isBrowserCompatible) { 177 | this.client1 = this.workerManager.createClient(AppWorker); 178 | this.client2 = this.workerManager.createClient(AppWorker); 179 | } else { 180 | this.client1 = this.workerManager.createClient(AppWorker, true); 181 | this.client2 = this.workerManager.createClient(AppWorker, true); 182 | } 183 | } 184 | 185 | async createWorkers() { 186 | await Promise.all([this.client1.connect(), this.client2.connect()]); 187 | } 188 | 189 | } 190 | ``` 191 | 192 | ## Accessing Worker Properties 193 | 194 | When a worker property is decorated with `@Accessable()` that property can be accessed by a client through the `WorkerClient.get()` and `WorkerClient.set()` methods. Both of these methods are asynchronous and take a lambda expression as the first argument which returns the targeted property. Again, data that sent to the worker through the set operation, and returned from the worker through the get operation is serialized. Therefore, classes that have methods should generally be avoided. A `shallowTransfer` option can be provided as optional parameter to the decorator which transfers the prototype of the object or class that is sent to, or recieved from a worker. This allows the methods of the class/object to be copied, however, it is more of an experimental feature as it does have limitations which are discussed in more detail further below. 195 | 196 | *app.worker.ts* 197 | ```typescript 198 | import { AngularWebWorker, bootstrapWorker, OnWorkerInit, Accessable } from 'angular-web-worker'; 199 | /// 200 | 201 | @AngularWebWorker() 202 | export class AppWorker implements OnWorkerInit { 203 | 204 | @Accessable() names: string[]; 205 | 206 | constructor() {} 207 | 208 | onWorkerInit() { 209 | this.names = ['Joe', 'Peter', 'Mary']; 210 | } 211 | 212 | } 213 | bootstrapWorker(AppWorker); 214 | ``` 215 | 216 | *app.component.ts* 217 | ```typescript 218 | import { AppWorker } from './app.worker'; 219 | import { WorkerManager, WorkerClient } from 'angular-web-worker/angular'; 220 | 221 | @Component({ 222 | ... 223 | }) 224 | export class AppComponent implements OnInit { 225 | 226 | private client: WorkerClient; 227 | 228 | constructor(private workerManager: WorkerManager) {} 229 | 230 | ngOnInit() { 231 | if(this.workerManager.isBrowserCompatible) { 232 | this.client = this.workerManager.createClient(AppWorker); 233 | } else { 234 | this.client = this.workerManager.createClient(AppWorker, true); 235 | } 236 | } 237 | 238 | async updateWorkerNames() { 239 | // can use the Promise.then().catch() syntax if preferred 240 | await this.client.connect(); 241 | const workerNames = await this.client.get(w => w.names); 242 | await this.client.set(w => w.names, ['John']); 243 | } 244 | 245 | } 246 | ``` 247 | 248 | **Decorator Options:** *all options are optional* 249 | 250 | | Name | Description | Type | Default | 251 | | --------------- | ----------------------------------------- | --------- | ------: | 252 | | get | whether `WorkerClient.get()` can be used | boolean | true | 253 | | set | whether `WorkerClient.set()` can be used | boolean | true | 254 | | shallowTransfer | if the prototype is copied | boolean | false | 255 | 256 | *Example* 257 | ```typescript 258 | @Accessable({get: true, set: false, shallowTransfer: true}) names: string[]; 259 | ``` 260 | 261 | ## Calling Worker Methods 262 | 263 | When a worker method is decorated with `@Callable()` that method can be called with `WorkerClient.call()`. This is an asynchronous method that only resolves once the decorated method has completed. If the decorated method is also asynchrounous it will only resolve once that method has resolved. The `WorkerClient.call()` method only takes one argument which is a lamdba function that calls the targeted method. Similiar to the `@Accessable()` decorator, all data is serialized which applies to both the function arguments as well as the returned value. A `shallowTransfer` option can also passed into the decorator, however this only applies to the value returned by the decorated method. A seperate parameter decorator `@ShallowTransfer()` can be used on the method arguments. Read further below for more on the `shallowTransfer` feature. 264 | 265 | *app.worker.ts* 266 | ```typescript 267 | import { AngularWebWorker, bootstrapWorker, OnWorkerInit, Callable } from 'angular-web-worker'; 268 | /// 269 | 270 | @AngularWebWorker() 271 | export class AppWorker implements OnWorkerInit { 272 | 273 | constructor() { } 274 | 275 | onWorkerInit() { 276 | } 277 | 278 | @Callable() 279 | async doSomeWork(value1: string, value2: number): Promise { 280 | // execute some async code 281 | // this method can also be synchrounous 282 | return `${value1}-${value2 * 2}`; 283 | } 284 | 285 | } 286 | bootstrapWorker(AppWorker); 287 | ``` 288 | 289 | *app.component.ts* 290 | ```typescript 291 | import { AppWorker } from './app.worker'; 292 | import { WorkerManager, WorkerClient } from 'angular-web-worker/angular'; 293 | 294 | @Component({ 295 | ... 296 | }) 297 | export class AppComponent implements OnInit { 298 | 299 | private client: WorkerClient; 300 | 301 | constructor(private workerManager: WorkerManager) {} 302 | 303 | ngOnInit() { 304 | if(this.workerManager.isBrowserCompatible) { 305 | this.client = this.workerManager.createClient(AppWorker); 306 | } else { 307 | this.client = this.workerManager.createClient(AppWorker, true); 308 | } 309 | } 310 | 311 | async callWorkerMethod() { 312 | // can use the Promise.then().catch() syntax if preferred 313 | await this.client.connect(); 314 | const returnValue = await this.client.call(w => w.doSomeWork('value', 2000)); 315 | } 316 | 317 | } 318 | ``` 319 | **Decorator Options:** *all options are optional* 320 | 321 | | Name | Description | Type | Default | 322 | | --------------- | ------------------------------------------------- | --------- | ------: | 323 | | shallowTransfer | if the prototype of the returned value is copied | boolean | false | 324 | 325 | *Example* 326 | ```typescript 327 | @Callable({shallowTransfer: true}) 328 | doSomeWork(): MyClassWithMethods { 329 | return new MyClassWithMethods(); 330 | } 331 | ``` 332 | 333 | ## Subscribing to Worker Events 334 | 335 | When a RxJS subject property in a worker is decorated with `@Subscribable()` a client can create a subscription to the observable with `WorkerClient.subscribe()`. All types of multicasted RxJS observables are supported being a `Subject`, `BehaviorSubject`, `ReplaySubject` or `AsyncSubject`. The subscribe method has two required arguments, the first is a lambda expression returning the targeted subject and the second is the `next` callback. There are two optional arguments being the `onerror` and `complete` callbacks for the subscription. Again, any data that sent from the worker to the client is serialized. 336 | 337 | **UNSUBSCRIBING** 338 | 339 | The client's subscribe method returns a promise with the subsription, which can be unsubscribed from before the worker is terminated. However, it is not advisable to unsubscribe from the subscription with the normal `Subscription.unsubscribe()` approach. This is because two subscriptions are actually created, one within the Angular app and one within the worker script. Therefore, in order to release resources within the worker the `WorkerClient.unsubscribe(subscription)` method should be used. 340 | 341 | If there is no need to unsubscribe from the subscription before the worker is terminated, simply terminating the worker with the `WorkerClient.destroy()` method will properly dispose of the subscriptions. 342 | 343 | *app.worker.ts* 344 | ```typescript 345 | import { AngularWebWorker, bootstrapWorker, OnWorkerInit, Subscribable } from 'angular-web-worker'; 346 | import { BehaviorSubject } from 'rxjs'; 347 | /// 348 | 349 | @AngularWebWorker() 350 | export class AppWorker implements OnWorkerInit { 351 | 352 | @Subscribable() event: BehaviorSubject; 353 | 354 | constructor() {} 355 | 356 | onWorkerInit() { 357 | this.event = new BehaviorSubject(100); 358 | } 359 | 360 | } 361 | bootstrapWorker(AppWorker); 362 | ``` 363 | 364 | *app.component.ts* 365 | ```typescript 366 | import { AppWorker } from './app.worker'; 367 | import { WorkerManager, WorkerClient } from 'angular-web-worker/angular'; 368 | 369 | @Component({ 370 | ... 371 | }) 372 | export class AppComponent implements OnInit, OnDestroy { 373 | 374 | private client: WorkerClient; 375 | private subscription: Subscription; 376 | 377 | constructor(private workerManager: WorkerManager) { } 378 | 379 | ngOnInit() { 380 | if (this.workerManager.isBrowserCompatible) { 381 | this.client = this.workerManager.createClient(AppWorker); 382 | } else { 383 | this.client = this.workerManager.createClient(AppWorker, true); 384 | } 385 | } 386 | 387 | async subscribeToWorker() { 388 | await this.client.connect(); 389 | this.subscription = await this.client.subscribe(w => w.event, 390 | (no) => { console.log(no); }, 391 | // optional 392 | (err) => { console.log(err); }, 393 | () => { console.log('Done'); } 394 | ); 395 | } 396 | 397 | unsubscribeFromWorker() { 398 | this.client.unsubscribe(this.subscription); 399 | } 400 | 401 | ngOnDestroy() { 402 | // will unsubscribe from all subscriptions 403 | this.client.destroy(); 404 | } 405 | 406 | } 407 | ``` 408 | 409 | ## Creating Observables from Worker Events 410 | 411 | Similiar to subscribing to a worker event, a client can also create an RxJS observable from an event subject decorated with `@Subscribable()`. This is through the `WorkerClient.observe()` method, which returns a promise of the observable and takes one argument being the a lambda expression that returns the targeted event subject. 412 | 413 | **UNSUBSCRIBING** 414 | 415 | Typically there is no need to unsubscribe when only using observables, however if there is no need to retain an observable created from `WorkerClient.observe()` until worker is terminated then `WorkerClient.unsubscribe(observable)` method should be used to release resources within the worker. 416 | 417 | *app.worker.ts* 418 | ```typescript 419 | import { AngularWebWorker, bootstrapWorker, OnWorkerInit, Subscribable } from 'angular-web-worker'; 420 | /// 421 | 422 | @AngularWebWorker() 423 | export class AppWorker implements OnWorkerInit { 424 | 425 | @Subscribable() event: BehaviorSubject; 426 | 427 | constructor() {} 428 | 429 | onWorkerInit() { 430 | this.event = new BehaviorSubject(['value1','value2']); 431 | } 432 | 433 | } 434 | bootstrapWorker(AppWorker); 435 | ``` 436 | 437 | *app.component.ts* 438 | ```typescript 439 | import { AppWorker } from './app.worker'; 440 | import { WorkerManager, WorkerClient } from 'angular-web-worker/angular'; 441 | 442 | @Component({ 443 | ... 444 | }) 445 | export class AppComponent implements OnInit, OnDestroy { 446 | 447 | private client: WorkerClient; 448 | private observable$: Observable; 449 | 450 | constructor(private workerManager: WorkerManager) { } 451 | 452 | ngOnInit() { 453 | if (this.workerManager.isBrowserCompatible) { 454 | this.client = this.workerManager.createClient(AppWorker); 455 | } else { 456 | this.client = this.workerManager.createClient(AppWorker, true); 457 | } 458 | } 459 | 460 | async createObservable() { 461 | await this.client.connect(); 462 | this.observable$ = await this.client.observe(w => w.event); 463 | } 464 | 465 | removeObservable() { 466 | // remove observable before termination if needed 467 | this.client.unsubscribe(this.observable$); 468 | this.observable$ = null; 469 | } 470 | 471 | ngOnDestroy() { 472 | this.client.destroy(); 473 | } 474 | 475 | } 476 | ``` 477 | ## Terminating a Worker 478 | 479 | A worker script will remain active in the browser until the `WorkerClient.destroy()` method is called so it is important ensure that the worker is properly disposed of before an Angular component/directive is destroyed. 480 | 481 | ```typescript 482 | ngOnDestroy() { 483 | this.client.destroy(); 484 | } 485 | ``` 486 | 487 | ## ShallowTransfers 488 | 489 | The shallow transfer feature allows the prototype of the data sent to, or recieved from a worker to be copied after the data has been serialized. It can be used to copy the functions from a class, but it does have limitations as it will only copy the prototype class and will not copy the prototypes for any of its properties. Likewise for arrays, the array prototype is already natively detected by the browser so the use of the shallow transfer feature will have no effect as it will have no impact on the elements of the array. 490 | 491 | **USAGE** 492 | - `@Accessable({shallowTransfer: true})` - applies to both get and set operations 493 | - `@Callable({shallowTransfer: true})` - applies to the value returned by the decorated function 494 | - `@ShallowTransfer()` - decorator for arguments in a method decorated with @Callable() 495 | 496 | *person.ts* 497 | ```typescript 498 | class Person { 499 | 500 | name: string; 501 | age: number; 502 | spouse: Person; 503 | 504 | constructor() {} 505 | 506 | birthday() { 507 | this.age++; 508 | } 509 | } 510 | ``` 511 | *app.worker.ts* 512 | ```typescript 513 | import { AngularWebWorker, bootstrapWorker, OnWorkerInit, Callable, ShallowTransfer } from 'angular-web-worker'; 514 | /// 515 | 516 | @AngularWebWorker() 517 | export class AppWorker implements OnWorkerInit { 518 | 519 | @Callable() 520 | doSomethingWithPerson(@ShallowTransfer() person: Person) { 521 | // ok 522 | person.birthday(); 523 | // will throw error 524 | person.spouse.birthday(); 525 | } 526 | 527 | } 528 | ``` 529 | # Testing 530 | 531 | The library provides a set of utilities for writing unit tests. The testing utilities essentially run the worker within the application's "thread" and mock the serialization behaviour that occurs when messages are sent to, or recieved from the worker. This ensures that your code will work as expected when running in a seperate worker script. 532 | 533 | ## Testing the Worker Class 534 | 535 | When testing the worker class, all decorated methods and/or properties should be called/accessed through the `WorkerTestingClient` to mock the actual interaction with that class. A test client is an extension of the `WorkerClient` and exposes the underlying worker through an additional `workerInstance` property. This allows other functionality within the worker class to be tested/mocked directly. 536 | 537 | *app.worker.spec.ts* 538 | ```typescript 539 | import { createTestClient, WorkerTestingClient } from 'angular-web-worker/testing'; 540 | import { AppWorker } from './app.worker'; 541 | 542 | describe('AppWorker', () => { 543 | 544 | let client: WorkerTestingClient; 545 | 546 | beforeEach(async () => { 547 | client = createTestClient(AppWorker); 548 | await client.connect(); 549 | }); 550 | 551 | it('Should call doSomethingElse', async () => { 552 | const spy = spyOn(client.workerInstance, 'doSomethingElse') 553 | await client.call(w => w.doSomething()) 554 | expect(spy).toHaveBeenCalled(); 555 | }); 556 | 557 | }); 558 | 559 | ``` 560 | 561 | ## The Worker Testing Module 562 | 563 | When testing the usage of the injectable `WorkerManager` service within an Angular app, the `WorkerTestingModule` should be imported in place of the `WorkerModule`. It's usage is slightly different in that the path to the worker class is not provided in `WorkerTestingModule.forWorkers` as there is no need to generate seperate worker scripts. The testing module will then return a `WorkerTestingClient` when ever the `WorkerManager.createClient()` method is called 564 | 565 | *app.component.spec.ts* 566 | ```typescript 567 | import { WorkerTestingModule } from 'angular-web-worker/testing'; 568 | 569 | describe('AppComponent', () => { 570 | beforeEach(async(() => { 571 | TestBed.configureTestingModule({ 572 | imports: [ 573 | RouterTestingModule, 574 | WorkerTestingModule.forWorkers([AppWorker]) 575 | ], 576 | declarations: [ 577 | AppComponent, 578 | ], 579 | }).compileComponents(); 580 | })); 581 | }); 582 | 583 | ``` 584 | -------------------------------------------------------------------------------- /angular/src/lib/worker-client.ts: -------------------------------------------------------------------------------- 1 | import { Subject, Subscription, Observable } from 'rxjs'; 2 | import { WorkerClientObservablesDict, WorkerClientRequestOpts } from './worker-client-types'; 3 | import { 4 | WorkerResponseEvent, WorkerEvents, WorkerUtils, 5 | WorkerAnnotations, NonObservablesOnly, AccessableMetaData, FunctionsOnly, 6 | CallableMetaData, ObservablesOnly, WorkerObservableType, WorkerRequestEvent, 7 | SecretResult, 8 | WorkerEvent, 9 | WorkerObservableMessage, 10 | WorkerObservableMessageTypes 11 | } from 'angular-web-worker/common'; 12 | import { WorkerDefinition } from './worker.module'; 13 | import { ClientWebWorker } from './client-web-worker'; 14 | 15 | 16 | /** 17 | * Provides functionality for an Angular app to access the properties, call the methods and subscribe to the events in a web worker by managing 18 | * the communication between the app and the worker. Also provides the option to execute the worker code within the app should the browser not support web workers, 19 | * although intensive work may then block the UI. 20 | */ 21 | export class WorkerClient { 22 | /** 23 | * Reference to the browser's worker class for posting messages and terminating the worker 24 | */ 25 | private workerRef: Worker | ClientWebWorker; 26 | /** 27 | * The client instance of the worker class 28 | */ 29 | private worker: T; 30 | /** 31 | * A secret key that must be returned when decorated properties and/or methods are called from the client instance of the worker class 32 | */ 33 | private workerSecret?: string; 34 | /** 35 | * Array of secret keys containing the `workerSecret` and `WorkerRequestEvent.requestSecret`s ensuring that there are never two of the same keys at any point in time 36 | */ 37 | private secrets: string[]; 38 | /** 39 | * An event subject that is triggered each time a response is recieved from a `WorkerController`. This is subscribed to immediately before any request is made in the `sendRequest()` method. 40 | * This allows the `Worker.onmessage` listener to be mapped back to an async function call from where the request originated 41 | */ 42 | private responseEvent: Subject>; 43 | /** 44 | * A dictionary of observable references that listen for events triggered by the worker after they have been subscribed or observed through the use of either the `subscribe()` or `observe` methods 45 | */ 46 | private observables: WorkerClientObservablesDict; 47 | /** 48 | * Whether the worker is active after it is created with the `connect()` method and before it has been terminated by the `destroy()` method 49 | */ 50 | private _isConnected: boolean; 51 | 52 | /** 53 | * Creates a new `WorkerClient` 54 | * @param definition the worker defintion originating from the arguments of the `WorkerModule.forWorkers()` method 55 | * @param runInApp whether the execution of the worker will occur in the app or within the worker script 56 | * @param runInApp whether the client is used for unit testing which determines if serialization should be mocked 57 | */ 58 | constructor(private definition: WorkerDefinition, private runInApp: boolean = false, private isTestClient: boolean = false) { 59 | } 60 | 61 | /** 62 | * Creates a new worker script in the browser, or within the app, and triggers the `OnWorkerInit` hook, if implemented. If the hook is implemented the promise will only be resolved once `onWorkerInit` method 63 | * has completed regardless of whether it is async or not 64 | * 65 | * This method must called before any worker methods and/or properties can be called/accessed 66 | */ 67 | connect(): Promise { 68 | if (!this._isConnected) { 69 | this.secrets = []; 70 | this.workerSecret = this.generateSecretKey(); 71 | this.worker = WorkerUtils.getAnnotation(this.definition.worker, WorkerAnnotations.Factory)({ 72 | isClient: true, 73 | clientSecret: this.workerSecret 74 | }); 75 | 76 | if (!this.runInApp) { 77 | this.workerRef = this.definition.initFn(); 78 | } else { 79 | this.workerRef = new ClientWebWorker(this.definition.worker, this.isTestClient); 80 | } 81 | this.registerEvents(); 82 | 83 | return this.castPromise(this.sendRequest(WorkerEvents.Init, { 84 | body: () => null, 85 | isConnect: true, 86 | resolve: () => { 87 | this._isConnected = true; 88 | return undefined; 89 | }, 90 | secretError: 'Could not initialise worker' 91 | })); 92 | } 93 | } 94 | 95 | /** 96 | * Terminates the worker and unsubscribes from any subscriptions created from the `subscribe()` method 97 | */ 98 | destroy(): void { 99 | if (this.isConnected) { 100 | for (const key in this.observables) { 101 | if (this.observables[key]) { 102 | this.removeSubscription(key); 103 | } 104 | } 105 | this.workerRef.terminate(); 106 | this.secrets = []; 107 | this.observables = {}; 108 | this.worker = null; 109 | this._isConnected = false; 110 | } 111 | } 112 | 113 | /** 114 | * Whether the worker is active after it is created with the `connect()` method and before it has been terminated by the `destroy()` method 115 | */ 116 | get isConnected(): boolean { 117 | return this._isConnected; 118 | } 119 | 120 | /** 121 | * Returns the value of a worker property that has been decorated with `@Accessable()`. Undecorated properties will cause the promise to be rejected 122 | * @Serialized 123 | * @example 124 | * // async await syntax --- 125 | * const name: string = await client.get(w => w.name); 126 | * 127 | * // promise syntax --- 128 | * client.get(w => w.name).then((name) => { 129 | * console.log(name); 130 | * }).catch((err) => { 131 | * console.log(err); 132 | * }); 133 | * @param property A lamda expression that returns the targeted property of the worker. The worker argument in the expression only has the properties owned by the worker class (no methods) 134 | * and only those properties that are not RxJS subjects 135 | */ 136 | get( 137 | property: (workerProperties: NonObservablesOnly) => PropertyType 138 | ): Promise { 139 | 140 | return this.sendRequest(WorkerEvents.Accessable, { 141 | workerProperty: property, 142 | additionalConditions: [{ 143 | if: (secret) => secret.body.get, 144 | reject: (secret) => new Error(`WorkerClient: will not apply the get method to the "${secret.propertyName}" property because the get accessor has been explicity set to false`) 145 | }], 146 | secretError: 'WorkerClient: only properties decorated with @Accessable() can be used in the get method', 147 | body: () => { return { isGet: true }; }, 148 | resolve: (resp) => { 149 | const metaData = WorkerUtils.getAnnotation(this.definition.worker, WorkerAnnotations.Accessables).filter(x => x.name === resp.propertyName)[0]; 150 | if (metaData.shallowTransfer) { 151 | if (metaData.type) { 152 | if (metaData.type.prototype && resp.result) { 153 | resp.result.__proto__ = metaData.type.prototype; 154 | } 155 | } 156 | 157 | } 158 | return resp.result; 159 | } 160 | }); 161 | 162 | } 163 | 164 | /** 165 | * Sets value of a worker property that has been decorated with `@Accessable()`. Undecorated properties will cause the promise to be rejected 166 | * @Serialized 167 | 168 | * @example 169 | * // async await syntax --- 170 | * await client.set(w => w.name, 'peter'); 171 | * 172 | * // promise syntax --- 173 | * client.set(w => w.name, 'peter').then(() => { 174 | * console.log('property has been set'); 175 | * }).catch((err) => { 176 | * console.log(err); 177 | * }); 178 | * @param property A lamda expression that returns the targeted property of the worker. The worker argument in the expression only has the properties owned by the worker class (no methods) 179 | * and only those properties that are not RxJS subjects 180 | * @param value the value which the property should be set to 181 | */ 182 | set( 183 | property: (workerProperties: NonObservablesOnly) => PropertyType, 184 | value: PropertyType 185 | ): Promise { 186 | return this.castPromise(this.sendRequest(WorkerEvents.Accessable, { 187 | workerProperty: property, 188 | additionalConditions: [{ 189 | if: (secret) => secret.body.set, 190 | reject: (secret) => new Error(`WorkerClient: will not apply the set method to the "${secret.propertyName}" property because the set accessor has been explicity set to false`) 191 | }], 192 | secretError: 'WorkerClient: only properties decorated with @Accessable() can be used in the set method', 193 | body: () => { return { isGet: false, value: value }; } 194 | })); 195 | } 196 | 197 | /** 198 | * Calls a method in the worker and returns its value. The called method can be either synchronous or asynchronous 199 | * but must be decorated with `@Callable()` else the promise will be rejected 200 | * @Serialized Applies to both the function arguments and the returned value 201 | * @example 202 | * // async await syntax --- 203 | * const functionResult: SomeResultType = await client.call(w => w.doSomeWork('someArgument', 2123)); 204 | * 205 | * // promise syntax --- 206 | * client.call(w => w.doSomeWork('someArgument', 2123)).then((result) => { 207 | * console.log(result); 208 | * }).catch((err) => { 209 | * console.log(err); 210 | * }); 211 | * @param property A lamda expression that calls the worker method. The worker argument in the expression only has the methods owned by the worker class (not the properties) 212 | */ 213 | call( 214 | callFn: (workerFunctions: FunctionsOnly) => ReturnType 215 | ): ReturnType extends Promise ? ReturnType : Promise { 216 | 217 | return this.sendRequest(WorkerEvents.Callable, { 218 | workerProperty: callFn, 219 | secretError: 'WorkerClient: only methods decorated with @Callable() can be used in the call method', 220 | body: (secret) => { return { arguments: secret.body.args }; }, 221 | resolve: (resp) => { 222 | const metaData = WorkerUtils.getAnnotation(this.definition.worker, WorkerAnnotations.Callables, []).filter(x => x.name === resp.propertyName)[0]; 223 | if (metaData.shallowTransfer) { 224 | if (metaData.returnType === Promise) { 225 | throw new Error('WorkerClient: shallowTransfer will not be true in the @Callable() decorator when the decorated method returns a promise'); 226 | } 227 | if (metaData.returnType && resp.result) { 228 | resp.result.__proto__ = metaData.returnType.prototype; 229 | } 230 | } 231 | return resp.result; 232 | } 233 | }); 234 | 235 | } 236 | 237 | /** 238 | * Subscribes to a worker's RxJS subject, which has been decorated with `@Subscribable()`, and then returns this subscription. 239 | * Supports all four RxJS subjects being `Subject`, `BehaviorSubject`, `ReplaySubject` and `AsyncSubject`. 240 | * 241 | * **UNSUBSCRIBING** 242 | * 243 | * While the returned subscription can be destroyed with `Subscription.unsubscribe()` this is only destroys the client subscription. A subscription is also created in the worker. 244 | * To release the resources in both the client and the worker the `WorkerClient.unsubscribe(subscription)` method should be used. The `WorkerClient.destroy()` method will 245 | * dispose of all subscriptions correctly. 246 | * 247 | * @Serialized This applies to messages posted through `Subject.next()` 248 | * @example 249 | * // async await syntax --- 250 | * this.workerSubscription = await client.subscribe(w => w.someEventSubject); 251 | * 252 | * // promise syntax --- 253 | * client.subscribe(w => w.someEventSubject).then((subscription) => { 254 | * this.workerSubscription = subscription; 255 | * }).catch((err) => { 256 | * console.log(err); 257 | * }); 258 | * 259 | * // unsubscribing -------- 260 | * await client.unsubscribe(this.workerSubscription) 261 | * @param observable A lamda expression that returns the targeted RxJS subject of the worker. The worker argument in the expression only has the properties owned by the worker class (no methods) 262 | * and only those properties that are RxJS subjects 263 | * @param next Callback function that is triggered when the subject's `next()` method is called within the worker 264 | * @param error Callback function that is triggered when the subject throws and error 265 | * @param complete Callback function that is triggered when the subject's `complete()` method is called within the worker 266 | */ 267 | subscribe( 268 | observable: (workerObservables: ObservablesOnly) => WorkerObservableType, 269 | next: (value: ObservableType) => void, 270 | error?: (error: any) => void, 271 | complete?: () => void 272 | ): Promise { 273 | return this.castPromise(this.sendRequest(WorkerEvents.Observable, { 274 | workerProperty: observable, 275 | secretError: 'WorkerClient: only methods decorated with @Callable() can be used in the call method', 276 | beforeRequest: (secret) => this.createSubscription(secret.propertyName, next, error, complete), 277 | body: (secret, key) => { return { isUnsubscribe: false, subscriptionKey: key }; }, 278 | resolve: (resp, secret, key) => this.observables[key].subscription, 279 | beforeReject: (resp, secret, key) => this.removeSubscription(key) 280 | })); 281 | } 282 | 283 | /** 284 | * Creates and returns a RxJS observable that is in sync with a RxJS subject within a worker. The worker subject must be decorated with `@Subscribable()` otherwise the 285 | * promise will be rejected. Supports all four RxJS subjects being, `Subject`, `BehaviorSubject`, `ReplaySubject` and `AsyncSubject`. 286 | * 287 | * **UNSUBSCRIBING** 288 | * 289 | * While under normal circumstances you don't need to unsubscribe from an RxJS observable, when an observable is created from a worker subject a subscription is also created in the worker. 290 | * To release the resources in the worker the `WorkerClient.unsubscribe(observable)` method should be used. The `WorkerClient.destroy()` method will 291 | * dispose of all observables correctly. 292 | * 293 | * @Serialized 294 | * @example 295 | * // async await syntax --- 296 | * this.observable$ = await client.observe(w => w.someEventSubject); 297 | * 298 | * // promise syntax --- 299 | * client.observe(w => w.someEventSubject).then((observable) => { 300 | * this.observable$ = observable; 301 | * }).catch((err) => { 302 | * console.log(err); 303 | * }); 304 | * 305 | * // unsubscribing -------- 306 | * await client.unsubscribe(this.observable$) 307 | * @param observable A lamda expression that returns the targeted RxJS subject of the worker. The worker argument in the expression only has the properties owned by the worker class (no methods) 308 | * and only those properties that are RxJS subjects 309 | */ 310 | observe( 311 | observable: (workerObservables: ObservablesOnly) => WorkerObservableType, 312 | ): Promise> { 313 | 314 | return this.castPromise>(this.sendRequest(WorkerEvents.Observable, { 315 | workerProperty: observable, 316 | secretError: 'WorkerClient: only methods decorated with @Callable() can be used in the call method', 317 | beforeRequest: (secret) => this.createObservable(secret.propertyName), 318 | body: (secret, key) => { return { isUnsubscribe: false, subscriptionKey: key }; }, 319 | resolve: (resp, secret, key) => this.observables[key].observable, 320 | beforeReject: (resp, secret, key) => this.removeSubscription(key) 321 | })); 322 | } 323 | 324 | /** 325 | * Unsubscribes from an RxJS subscription or observable that has been created from the `WorkerClient.subscribe()` or `WorkerClient.observe()` methods respectively. 326 | * This method is neccessary to release resources within the worker. Calling `WorkerClient.destory()` will also dispose of all observables/subscriptions 327 | * @param subscriptionOrObservable The observable or subscription that must be disposed of 328 | */ 329 | unsubscribe( 330 | subscriptionOrObservable: Subscription | Observable 331 | ): Promise { 332 | 333 | const key = this.findObservableKey(subscriptionOrObservable); 334 | if (key) { 335 | const propertyName: string = this.observables[key].propertyName; 336 | this.removeSubscription(key); 337 | return this.castPromise(this.sendRequest(WorkerEvents.Observable, { 338 | workerProperty: propertyName, 339 | secretError: '', 340 | body: (secret) => { return { isUnsubscribe: true, subscriptionKey: key }; }, 341 | })); 342 | 343 | } else { 344 | return new Promise((resolve) => resolve()); 345 | } 346 | } 347 | 348 | /** 349 | * A generic utility function for sending requests to, and handling the responses from a `WorkerController` used when the `runInApp` property is set to `false` 350 | * @param type the type of worker event 351 | * @param opts Configurable options that defines how the request is sent and how the response is handled 352 | */ 353 | private sendRequest( 354 | type: EventType, 355 | opts: WorkerClientRequestOpts 356 | ): ReturnType extends Promise ? ReturnType : Promise { 357 | const promise = new Promise((resolve, reject) => { 358 | if (this._isConnected || opts.isConnect) { 359 | try { 360 | const noProperty = opts.workerProperty === undefined; 361 | const secretResult = noProperty ? null : this.isSecret(typeof opts.workerProperty === 'string' ? this.worker[opts.workerProperty] : opts.workerProperty(this.worker), type); 362 | if (secretResult || noProperty) { 363 | // additional checks --- 364 | if (opts.additionalConditions) { 365 | for (const opt of opts.additionalConditions) { 366 | if (!opt.if(secretResult)) { 367 | reject(opt.reject(secretResult)); 368 | return; 369 | } 370 | } 371 | } 372 | 373 | // additional functionality --- 374 | let additionalContext: any; 375 | if (opts.beforeRequest) { 376 | additionalContext = opts.beforeRequest(secretResult); 377 | } 378 | 379 | // response ---- 380 | const requestSecret = this.generateSecretKey(); 381 | const repsonseSubscription = this.responseEvent.subscribe((resp) => { 382 | try { 383 | let isValidReponse = resp.type === type && resp.requestSecret === requestSecret; 384 | isValidReponse = noProperty ? isValidReponse : (isValidReponse && secretResult.propertyName === resp.propertyName); 385 | 386 | if (isValidReponse) { 387 | if (!resp.isError) { 388 | 389 | // resolve ---- 390 | this.removeSecretKey(requestSecret); 391 | if (opts.resolve) { 392 | resolve(opts.resolve(resp, secretResult, additionalContext)); 393 | } else { 394 | resolve(); 395 | } 396 | repsonseSubscription.unsubscribe(); 397 | 398 | } else { 399 | 400 | // reject ----- 401 | this.removeSecretKey(requestSecret); 402 | if (opts.beforeReject) { 403 | opts.beforeReject(resp, secretResult, additionalContext); 404 | } 405 | repsonseSubscription.unsubscribe(); 406 | reject(JSON.parse(resp.error)); 407 | } 408 | 409 | } 410 | } catch (e) { 411 | reject(e); 412 | } 413 | }); 414 | 415 | // send request ----- 416 | const req: WorkerRequestEvent = { 417 | requestSecret: requestSecret, 418 | propertyName: noProperty ? null : secretResult.propertyName, 419 | type: type, 420 | body: opts.body ? opts.body(secretResult, additionalContext) : null 421 | }; 422 | this.postMessage(req); 423 | } else { 424 | reject(new Error(opts.secretError)); 425 | } 426 | } catch (e) { 427 | reject(e); 428 | } 429 | } else { 430 | reject(new Error('WorkerClient: the WorkerClient.connect() method must be called before a worker can be accessed')); 431 | } 432 | }); 433 | return promise; 434 | } 435 | 436 | /** 437 | * A wrapper function around the `Worker.postMessage()` method to catch any serialization errors should they occur 438 | * @param request the request to be sent to the worker 439 | */ 440 | private postMessage( 441 | request: WorkerRequestEvent 442 | ) { 443 | try { 444 | this.workerRef.postMessage(request); 445 | } catch (e) { 446 | throw new Error('Unable to serialize the request from the client to the worker'); 447 | } 448 | } 449 | 450 | 451 | /** 452 | * A utility function to cast promises 453 | * @param promise promise to cast 454 | */ 455 | private castPromise( 456 | promise: Promise 457 | ): Promise { 458 | return >promise; 459 | } 460 | 461 | /** 462 | * Creates client subscription reference with a subscription and an RxJS subject, adds it to the `observables` dictionary with unique key and then returns the key. Called from the `subscribe()` method. 463 | * @param propertyName the property name of the worker's RxJS subject that was subscribed to 464 | * @param next Callback function that is triggered when the subject's `next()` method is called 465 | * @param error Callback function that is triggered when the subject throws and error 466 | * @param complete Callback function that is triggered when the subject's `complete()` method is called 467 | */ 468 | private createSubscription( 469 | propertyName: string, 470 | next?: (value: any) => void, 471 | error?: (error: any) => void, 472 | complete?: () => void 473 | ): string { 474 | const key = this.generateSubscriptionKey(propertyName); 475 | const subject = new Subject(); 476 | const subscription = subject.subscribe(next, error, complete); 477 | this.observables[key] = { subject: subject, subscription: subscription, propertyName: propertyName, observable: null }; 478 | return key; 479 | } 480 | 481 | /** 482 | * Creates client observable reference with a RxJS observable and subject, adds it to the `observables` dictionary with unique key and then returns the key. Called from the `observe()` method. 483 | * @param propertyName the property name of the worker's RxJS subject that was subscribed to 484 | */ 485 | private createObservable( 486 | propertyName: string, 487 | ): string { 488 | const key = this.generateSubscriptionKey(propertyName); 489 | const subject = new Subject(); 490 | this.observables[key] = { subject: subject, subscription: null, propertyName: propertyName, observable: subject.asObservable() }; 491 | return key; 492 | } 493 | 494 | /** 495 | * Iterates through the `observables` dictionary to find the associated key for a particular subscription or observable. Returns null if no match is found 496 | * @param value Subscription or observable for which the dictionary key must be found 497 | */ 498 | private findObservableKey( 499 | value: Subscription | Observable 500 | ): string { 501 | for (const key in this.observables) { 502 | if (value instanceof Subscription) { 503 | if (this.observables[key].subscription === value) { 504 | return key; 505 | } 506 | } else { 507 | if (this.observables[key].observable === value) { 508 | return key; 509 | } 510 | } 511 | } 512 | return null; 513 | } 514 | 515 | /** 516 | * Remove a subscription or observable reference from `observables` dictionary. Removed subscriptions are unsubsribed before destroyed 517 | * @param subscriptionKey unique key in the `observables` dictionary 518 | */ 519 | private removeSubscription( 520 | subscriptionKey: string 521 | ) { 522 | if (this.observables[subscriptionKey]) { 523 | if (this.observables[subscriptionKey].subscription) { 524 | this.observables[subscriptionKey].subscription.unsubscribe(); 525 | } 526 | } 527 | delete this.observables[subscriptionKey]; 528 | } 529 | 530 | /** 531 | * Generates a random key 532 | * @param propertyName appended as the prefix to the key 533 | * @param length length of the randomly generated characters 534 | */ 535 | private generateKey( 536 | propertyName: string, 537 | length: number 538 | ) { 539 | return `${propertyName.toUpperCase()}_${Array(length).fill(null).map(() => (Math.round(Math.random() * 16)).toString(16)).join('')}`; 540 | } 541 | 542 | /** 543 | * Creates a unique key for a subscription/observable reference for use in the `observables` dictionary. This key allows messages from the worker to be correctly mapped and handled in the client 544 | * @param propertyName property name of the worker's RxJS subject which is subscribed to. This is attached as a prefix to the unique key 545 | */ 546 | private generateSubscriptionKey( 547 | propertyName: string 548 | ): string { 549 | let key = this.generateKey(propertyName, 6); 550 | while (this.observables[key]) { 551 | key = this.generateKey(propertyName, 6); 552 | } 553 | return key; 554 | } 555 | 556 | /** 557 | * Creates a unique key for worker requests ensuring no two keys are avaliable at any time through the `secrets` array. Allows requests to be mapped to responses from 558 | * the worker 559 | * @param propertyName property name of the worker's property/method that is being called. This is attached as a prefix to the unique key 560 | */ 561 | private generateSecretKey( 562 | propertyName?: string 563 | ): string { 564 | propertyName = propertyName ? propertyName : 'client'; 565 | let key = this.generateKey(propertyName, 16); 566 | while (this.secrets.indexOf(key) !== -1) { 567 | key = this.generateKey(propertyName, 16); 568 | } 569 | this.secrets.push(key); 570 | return key; 571 | } 572 | 573 | /** 574 | * Removes a key from the `secrets` array if it exists 575 | * @param secret unqiue key to be removed 576 | */ 577 | private removeSecretKey( 578 | secret: string 579 | ) { 580 | if (this.secrets.indexOf(secret) !== -1) { 581 | this.secrets.splice(this.secrets.indexOf(secret), 1); 582 | } 583 | } 584 | 585 | /** 586 | * Checks if a valid `SecretResult` is returned when a decorated property and/or method of the client instance of the worker class is called. 587 | * Returns the secret when valid otherwise returns null 588 | * @param secretResult the returned value from calling the property or method of a client instance of a worker 589 | * @param type the worker event type that originated the request 590 | */ 591 | private isSecret( 592 | secretResult: any, 593 | type: SecretType 594 | ): SecretResult { 595 | if (secretResult) { 596 | if (secretResult['clientSecret'] && secretResult['propertyName'] && secretResult['type']) { 597 | if (secretResult['clientSecret'] === this.workerSecret && secretResult['type'] === type) { 598 | return >secretResult; 599 | } 600 | } 601 | } 602 | return null; 603 | } 604 | 605 | /** 606 | * Creates the event listeners to listen for, and handle, messages recieved through `Worker.onmessage` 607 | */ 608 | private registerEvents() { 609 | 610 | this.responseEvent = new Subject>(); 611 | this.observables = {}; 612 | 613 | this.workerRef.onmessage = (ev: WorkerEvent>) => { 614 | switch (ev.data.type) { 615 | case WorkerEvents.ObservableMessage: 616 | const body: WorkerObservableMessage = ev.data.result; 617 | if (this.observables[body.key]) { 618 | switch (body.type) { 619 | case WorkerObservableMessageTypes.Next: 620 | this.observables[body.key].subject.next(body.value); 621 | break; 622 | case WorkerObservableMessageTypes.Error: 623 | this.observables[body.key].subject.error(body.error); 624 | break; 625 | case WorkerObservableMessageTypes.Complete: 626 | this.observables[body.key].subject.complete(); 627 | } 628 | } 629 | break; 630 | default: 631 | this.responseEvent.next(ev.data); 632 | } 633 | }; 634 | 635 | 636 | } 637 | 638 | 639 | } 640 | --------------------------------------------------------------------------------