From 8ce633e6d0ca07191014f7ea2952c6f8b8955c82 Mon Sep 17 00:00:00 2001 From: splincode <> Date: Fri, 5 May 2023 01:19:03 +0300 Subject: [PATCH] feat: workers --- .github/workflows/test.yml | 6 + apps/demo/src/app/app.routes.ts | 5 + apps/demo/src/app/constants/demo-path.ts | 1 + .../app/pages/home/home-page.component.html | 2 +- .../pages/speech/speech-page.component.html | 196 +++++++++--------- .../app/pages/speech/speech-page.component.ts | 5 +- .../src/app/pages/workers/clock.component.ts | 14 ++ .../pages/workers/workers-page.component.html | 14 ++ .../pages/workers/workers-page.component.less | 13 ++ .../pages/workers/workers-page.component.ts | 31 +++ .../app/pages/workers/workers-page.module.ts | 16 ++ libs/universal/src/mocks.js | 2 + libs/workers/LICENSE | 21 ++ libs/workers/README.md | 116 +++++++++++ libs/workers/karma.conf.js | 43 ++++ libs/workers/logo.svg | 76 +++++++ libs/workers/ng-package.json | 7 + libs/workers/package.json | 32 +++ libs/workers/project.json | 35 ++++ libs/workers/src/index.ts | 12 ++ libs/workers/src/worker/classes/web-worker.ts | 99 +++++++++ .../src/worker/consts/worker-fn-template.ts | 22 ++ libs/workers/src/worker/operators/to-data.ts | 7 + libs/workers/src/worker/pipes/worker.pipe.ts | 41 ++++ .../src/worker/types/typed-message-event.ts | 3 + .../src/worker/types/worker-function.ts | 1 + libs/workers/src/worker/worker.module.ts | 8 + libs/workers/test.ts | 23 ++ libs/workers/tests/web-worker.spec.ts | 113 ++++++++++ libs/workers/tests/worker.pipe.spec.ts | 49 +++++ libs/workers/tsconfig.spec.json | 5 + tsconfig.build.json | 1 + tsconfig.json | 1 + 33 files changed, 923 insertions(+), 97 deletions(-) create mode 100644 apps/demo/src/app/pages/workers/clock.component.ts create mode 100644 apps/demo/src/app/pages/workers/workers-page.component.html create mode 100644 apps/demo/src/app/pages/workers/workers-page.component.less create mode 100644 apps/demo/src/app/pages/workers/workers-page.component.ts create mode 100644 apps/demo/src/app/pages/workers/workers-page.module.ts create mode 100644 libs/workers/LICENSE create mode 100644 libs/workers/README.md create mode 100644 libs/workers/karma.conf.js create mode 100644 libs/workers/logo.svg create mode 100644 libs/workers/ng-package.json create mode 100644 libs/workers/package.json create mode 100644 libs/workers/project.json create mode 100644 libs/workers/src/index.ts create mode 100644 libs/workers/src/worker/classes/web-worker.ts create mode 100644 libs/workers/src/worker/consts/worker-fn-template.ts create mode 100644 libs/workers/src/worker/operators/to-data.ts create mode 100644 libs/workers/src/worker/pipes/worker.pipe.ts create mode 100644 libs/workers/src/worker/types/typed-message-event.ts create mode 100644 libs/workers/src/worker/types/worker-function.ts create mode 100644 libs/workers/src/worker/worker.module.ts create mode 100644 libs/workers/test.ts create mode 100644 libs/workers/tests/web-worker.spec.ts create mode 100644 libs/workers/tests/worker.pipe.spec.ts create mode 100644 libs/workers/tsconfig.spec.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d6896dd4a..612358521 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,7 @@ jobs: permissions, speech, storage, + workers, ] name: ${{ matrix.project }} steps: @@ -122,6 +123,11 @@ jobs: directory: ./coverage/storage/ flags: summary,storage name: storage + - uses: codecov/codecov-action@v3.1.3 + with: + directory: ./coverage/workers/ + flags: summary,workers + name: workers concurrency: group: test-${{ github.workflow }}-${{ github.ref }} diff --git a/apps/demo/src/app/app.routes.ts b/apps/demo/src/app/app.routes.ts index a27ad73ea..e7d608c32 100644 --- a/apps/demo/src/app/app.routes.ts +++ b/apps/demo/src/app/app.routes.ts @@ -82,6 +82,11 @@ export const appRoutes: Routes = [ loadChildren: async () => (await import(`./pages/storage/storage-page.module`)).StoragePageModule, }, + { + path: DemoPath.WorkersPage, + loadChildren: async () => + (await import(`./pages/workers/workers-page.module`)).WorkersPageModule, + }, { path: '', redirectTo: DemoPath.HomePage, diff --git a/apps/demo/src/app/constants/demo-path.ts b/apps/demo/src/app/constants/demo-path.ts index 9243c31aa..641b45f57 100644 --- a/apps/demo/src/app/constants/demo-path.ts +++ b/apps/demo/src/app/constants/demo-path.ts @@ -13,4 +13,5 @@ export enum DemoPath { MidiPage = `midi`, PermissionsPage = `permissions`, StoragePage = `storage`, + WorkersPage = `workers`, } diff --git a/apps/demo/src/app/pages/home/home-page.component.html b/apps/demo/src/app/pages/home/home-page.component.html index bd0750980..48ded43cd 100644 --- a/apps/demo/src/app/pages/home/home-page.component.html +++ b/apps/demo/src/app/pages/home/home-page.component.html @@ -220,7 +220,7 @@

Storage

class="icon" /> - +

Workers

A library for use of diff --git a/apps/demo/src/app/pages/speech/speech-page.component.html b/apps/demo/src/app/pages/speech/speech-page.component.html index ff626664d..2a0e7cf96 100644 --- a/apps/demo/src/app/pages/speech/speech-page.component.html +++ b/apps/demo/src/app/pages/speech/speech-page.component.html @@ -1,103 +1,109 @@ - - - Voice - - + + + + Text + +
+ - - - - Text - -
- - Tip: say «Show sidebar» -
+ Tip: say «Show sidebar» +
-
- + diff --git a/apps/demo/src/app/pages/speech/speech-page.component.ts b/apps/demo/src/app/pages/speech/speech-page.component.ts index 71e9dbcf1..80bccfee0 100644 --- a/apps/demo/src/app/pages/speech/speech-page.component.ts +++ b/apps/demo/src/app/pages/speech/speech-page.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, Inject} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Inject, PLATFORM_ID} from '@angular/core'; import { continuous, isSaid, @@ -11,6 +11,7 @@ import { import {TuiContextWithImplicit, tuiPure} from '@taiga-ui/cdk'; import {merge, Observable, repeat, retry} from 'rxjs'; import {filter, mapTo, share} from 'rxjs/operators'; +import {isPlatformBrowser} from '@angular/common'; @Component({ selector: `speech-page`, @@ -19,6 +20,7 @@ import {filter, mapTo, share} from 'rxjs/operators'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SpeechPageComponent { + readonly isBrowser = isPlatformBrowser(this.platformId); paused = true; voice = null; @@ -30,6 +32,7 @@ export class SpeechPageComponent { }: TuiContextWithImplicit) => $implicit.name; constructor( + @Inject(PLATFORM_ID) readonly platformId: Record, @Inject(SPEECH_SYNTHESIS_VOICES) readonly voices$: Observable, @Inject(SpeechRecognitionService) diff --git a/apps/demo/src/app/pages/workers/clock.component.ts b/apps/demo/src/app/pages/workers/clock.component.ts new file mode 100644 index 000000000..10915a5f5 --- /dev/null +++ b/apps/demo/src/app/pages/workers/clock.component.ts @@ -0,0 +1,14 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {Observable, timer} from 'rxjs'; +import {map} from 'rxjs/operators'; + +@Component({ + selector: 'app-clock', + template: ` + {{ date$ | async | date: 'mediumTime' }} + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ClockComponent { + readonly date$: Observable = timer(0, 1000).pipe(map(() => Date.now())); +} diff --git a/apps/demo/src/app/pages/workers/workers-page.component.html b/apps/demo/src/app/pages/workers/workers-page.component.html new file mode 100644 index 000000000..2dd8054b2 --- /dev/null +++ b/apps/demo/src/app/pages/workers/workers-page.component.html @@ -0,0 +1,14 @@ + + + +
+ +

Execution time: {{ workerData$ | async }}

+
+
+ +

Execution time: {{ result$ | async }}

+
+
diff --git a/apps/demo/src/app/pages/workers/workers-page.component.less b/apps/demo/src/app/pages/workers/workers-page.component.less new file mode 100644 index 000000000..1e8a83cf7 --- /dev/null +++ b/apps/demo/src/app/pages/workers/workers-page.component.less @@ -0,0 +1,13 @@ +:host { + perspective: 150vw; + user-select: none; + flex-direction: column; + align-items: center; +} + +.example { + min-width: 360px; + border-top: 1px solid gainsboro; + margin-top: 16px; + padding-top: 16px; +} diff --git a/apps/demo/src/app/pages/workers/workers-page.component.ts b/apps/demo/src/app/pages/workers/workers-page.component.ts new file mode 100644 index 000000000..b79364b9d --- /dev/null +++ b/apps/demo/src/app/pages/workers/workers-page.component.ts @@ -0,0 +1,31 @@ +import {ChangeDetectionStrategy, Component, Inject, PLATFORM_ID} from '@angular/core'; +import {toData, WebWorker} from '@ng-web-apis/workers'; +import {Subject} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {isPlatformBrowser} from '@angular/common'; + +function startCompute(): number { + const start = performance.now(); + + Array.from({length: 16000}).forEach((_, index) => + Array.from({length: index}).reduce((sum: number) => sum + 1, 0), + ); + + return performance.now() - start; +} + +@Component({ + selector: `workers-page`, + templateUrl: `./workers-page.component.html`, + styleUrls: [`./workers-page.component.less`], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkersPageComponent { + readonly isBrowser = isPlatformBrowser(this.platformId); + readonly workerThread = WebWorker.fromFunction(startCompute); + readonly workerData$ = this.workerThread.pipe(toData()); + readonly emitter: Subject = new Subject(); + readonly result$ = this.emitter.pipe(map(startCompute)); + + constructor(@Inject(PLATFORM_ID) readonly platformId: Record) {} +} diff --git a/apps/demo/src/app/pages/workers/workers-page.module.ts b/apps/demo/src/app/pages/workers/workers-page.module.ts new file mode 100644 index 000000000..1af196d46 --- /dev/null +++ b/apps/demo/src/app/pages/workers/workers-page.module.ts @@ -0,0 +1,16 @@ +import {NgModule} from '@angular/core'; +import {WorkersPageComponent} from './workers-page.component'; +import {RouterModule} from '@angular/router'; +import {CommonModule} from '@angular/common'; +import {WorkerModule} from '@ng-web-apis/workers'; +import {ClockComponent} from './clock.component'; + +@NgModule({ + imports: [ + CommonModule, + WorkerModule, + RouterModule.forChild([{path: '', component: WorkersPageComponent}]), + ], + declarations: [WorkersPageComponent, ClockComponent], +}) +export class WorkersPageModule {} diff --git a/libs/universal/src/mocks.js b/libs/universal/src/mocks.js index c30e2e72b..7551b50ce 100644 --- a/libs/universal/src/mocks.js +++ b/libs/universal/src/mocks.js @@ -39,6 +39,8 @@ global.DynamicsCompressorNode = class {}; global.GainNode = class {}; global.IIRFilterNode = class {}; + global.speechSynthesis = class {}; + global.SpeechSynthesisUtterance = class {}; global.PannerNode = class {}; global.ScriptProcessorNode = class {}; global.StereoPannerNode = class {}; diff --git a/libs/workers/LICENSE b/libs/workers/LICENSE new file mode 100644 index 000000000..c62979cb5 --- /dev/null +++ b/libs/workers/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Alexander Inkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/workers/README.md b/libs/workers/README.md new file mode 100644 index 000000000..e6b5b8bac --- /dev/null +++ b/libs/workers/README.md @@ -0,0 +1,116 @@ +# ![ng-web-apis logo](logo.svg) Web Workers API for Angular + +[![npm version](https://img.shields.io/npm/v/@ng-web-apis/workers.svg)](https://npmjs.com/package/@ng-web-apis/workers) +[![npm bundle size](https://img.shields.io/bundlephobia/minzip/@ng-web-apis/workers)](https://bundlephobia.com/result?p=@ng-web-apis/workers) +[![Coveralls github](https://img.shields.io/coveralls/github/ng-web-apis/workers)](https://coveralls.io/github/ng-web-apis/workers?branch=master) + +This is a library for comfortable use of +[Web Workers API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) with Angular. + +## Install + +If you do not have [@ng-web-apis/common](https://github.com/ng-web-apis/common): + +``` +npm i @ng-web-apis/common +``` + +Now install the package: + +``` +npm i @ng-web-apis/workers +``` + +## How it use + +Create a worker and use it in a template with `AsyncPipe`: + +```typescript +import {WebWorker} from '@ng-web-apis/workers'; + +function compute(data: number): number { + return data ** 2; +} + +@Component({ + template: ` + Computed Result: + {{ event.data }} +
+ + +
+ `, +}) +class SomeComponent { + readonly worker = WebWorker.fromFunction(compute); +} +``` + +To get data from the worker event, use the toData operator + +```typescript +import {toData, WebWorker} from '@ng-web-apis/workers'; + +function compute(data: number): number { + return data ** 2; +} + +@Component({ + template: ` + Computed Result: {{ workerData$ | async }} +
+ + +
+ `, +}) +class SomeComponent { + readonly worker = WebWorker.fromFunction(compute); + readonly workerData$ = this.worker.pipe(toData()); +} +``` + +It's the same with `WorkerPipe` only: + +```typescript +import {WorkerModule} from '@ng-web-apis/workers'; +import {NgModule} from '@angular/core'; + +@NgModule({ + imports: [WorkerModule], + declarations: [SomeComponent], +}) +class SomeModule {} +``` + +```typescript +import {WorkerExecutor, WebWorker} from '@ng-web-apis/workers'; +import {FormControl} from '@angular/forms'; + +@Component({ + template: ` + Computed Result: {{ value | waWorker: changeData | async }} + + + `, +}) +class SomeComponent { + value: string; + + changeData(data: string): string { + return `${data} (changed)`; + } +} +``` + +## See also + +Other [Web APIs for Angular](https://ng-web-apis.github.io/) by [@ng-web-apis](https://github.com/ng-web-apis) + +## Open-source + +Do you also want to open-source something, but hate the collateral work? Check out this +[Angular Open-source Library Starter](https://github.com/TinkoffCreditSystems/angular-open-source-starter) we’ve created +for our projects. It got you covered on continuous integration, pre-commit checks, linting, versioning + changelog, code +coverage and all that jazz. diff --git a/libs/workers/karma.conf.js b/libs/workers/karma.conf.js new file mode 100644 index 000000000..47c96bef0 --- /dev/null +++ b/libs/workers/karma.conf.js @@ -0,0 +1,43 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + client: { + clearContext: false, // leave Jasmine Spec Runner output visible in browser + }, + coverageIstanbulReporter: { + dir: require('path').join(__dirname, '../../coverage/workers'), + reports: ['html', 'lcovonly'], + fixWebpackSourcePaths: true, + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['ChromeHeadless'], + singleRun: true, + customLaunchers: { + ChromeHeadless: { + base: 'Chrome', + flags: [ + '--no-sandbox', + '--headless', + '--disable-gpu', + '--disable-web-security', + '--remote-debugging-port=9222', + ], + }, + }, + }); +}; diff --git a/libs/workers/logo.svg b/libs/workers/logo.svg new file mode 100644 index 000000000..9d0f2c7f3 --- /dev/null +++ b/libs/workers/logo.svg @@ -0,0 +1,76 @@ + + + + + background + + + + + + + Layer 1 + + + + + + + + + + + + + + + + diff --git a/libs/workers/ng-package.json b/libs/workers/ng-package.json new file mode 100644 index 000000000..b173a3470 --- /dev/null +++ b/libs/workers/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/workers", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/workers/package.json b/libs/workers/package.json new file mode 100644 index 000000000..7bea910b2 --- /dev/null +++ b/libs/workers/package.json @@ -0,0 +1,32 @@ +{ + "name": "@ng-web-apis/workers", + "version": "0.0.0-development", + "description": "A library for comfortable use of Web Workers API in Angular", + "keywords": [ + "angular", + "ng", + "worker", + "web", + "service", + "shared" + ], + "homepage": "https://github.com/ng-web-apis/workers#README", + "bugs": "https://github.com/ng-web-apis/workers/issues", + "repository": "https://github.com/ng-web-apis/workers", + "license": "MIT", + "author": { + "name": "Igor Katsuba", + "email": "katsuba.igor@gmail.com" + }, + "contributors": [ + "Alexander Inkin ", + "Roman Sedov <79601794011@ya.ru>" + ], + "peerDependencies": { + "@angular/core": ">=6.0.0", + "@ng-web-apis/common": ">=1.1.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/libs/workers/project.json b/libs/workers/project.json new file mode 100644 index 000000000..488e9b901 --- /dev/null +++ b/libs/workers/project.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "workers", + "root": "libs/workers", + "sourceRoot": "libs/workers", + "projectType": "library", + "targets": { + "test": { + "executor": "@angular-devkit/build-angular:karma", + "outputs": ["coverage/workers"], + "options": { + "main": "libs/workers/test.ts", + "tsConfig": "tsconfig.spec.json", + "karmaConfig": "libs/workers/karma.conf.js", + "codeCoverage": true, + "browsers": "ChromeHeadless" + } + }, + "build": { + "executor": "@angular-devkit/build-angular:ng-packagr", + "outputs": ["dist/workers"], + "options": { + "tsConfig": "tsconfig.build.json", + "project": "libs/workers/ng-package.json" + }, + "dependsOn": [ + { + "target": "build", + "projects": "dependencies", + "params": "forward" + } + ] + } + } +} diff --git a/libs/workers/src/index.ts b/libs/workers/src/index.ts new file mode 100644 index 000000000..a7a39baee --- /dev/null +++ b/libs/workers/src/index.ts @@ -0,0 +1,12 @@ +/** + * Public API Surface of @ng-web-apis/workers + */ +export * from './worker/classes/web-worker'; +export * from './worker/operators/to-data'; + +export * from './worker/pipes/worker.pipe'; + +export * from './worker/types/worker-function'; +export * from './worker/types/typed-message-event'; + +export * from './worker/worker.module'; diff --git a/libs/workers/src/worker/classes/web-worker.ts b/libs/workers/src/worker/classes/web-worker.ts new file mode 100644 index 000000000..0fc43740e --- /dev/null +++ b/libs/workers/src/worker/classes/web-worker.ts @@ -0,0 +1,99 @@ +import {EMPTY, fromEvent, merge, Observable, Subject} from 'rxjs'; +import {take, takeUntil, tap} from 'rxjs/operators'; +import {WORKER_BLANK_FN} from '../consts/worker-fn-template'; +import {TypedMessageEvent} from '../types/typed-message-event'; +import {WorkerFunction} from '../types/worker-function'; + +export class WebWorker extends Observable> { + private readonly worker: Worker | undefined; + private readonly url: string; + private readonly destroy$: Subject; + + constructor(url: string, options?: WorkerOptions) { + let worker: Worker | undefined; + let error: any; + + try { + worker = new Worker(url, options); + } catch (e) { + error = e; + } + + super(subscriber => { + let eventStream$: Observable | ErrorEvent> = EMPTY; + + if (error) { + subscriber.error(error); + } else if (this.destroy$.isStopped) { + subscriber.complete(); + } else if (worker) { + eventStream$ = merge( + fromEvent>(worker, 'message').pipe( + tap(event => subscriber.next(event)), + ), + fromEvent(worker, 'error').pipe( + tap(event => subscriber.error(event)), + ), + ).pipe(takeUntil(this.destroy$)); + } + + eventStream$.subscribe().add(subscriber); + }); + + this.worker = worker; + this.url = url; + this.destroy$ = new Subject(); + } + + static fromFunction( + fn: WorkerFunction, + options?: WorkerOptions, + ): WebWorker { + return new WebWorker(WebWorker.createFnUrl(fn), options); + } + + static execute( + fn: WorkerFunction, + data: T, + ): Promise> { + const worker = WebWorker.fromFunction(fn); + const promise = worker.pipe(take(1)).toPromise(); + + worker.postMessage(data); + + return promise.then(result => { + worker.terminate(); + + return result as unknown as TypedMessageEvent; + }); + } + + private static createFnUrl(fn: WorkerFunction): string { + const script = `(${WORKER_BLANK_FN})(${fn});`; + + const blob = new Blob([script], {type: 'text/javascript'}); + + return URL.createObjectURL(blob); + } + + terminate() { + if (this.destroy$.isStopped) { + return; + } + + if (this.worker) { + this.worker.terminate(); + } + + URL.revokeObjectURL(this.url); + + this.destroy$.next(); + this.destroy$.complete(); + } + + postMessage(value: T) { + if (this.worker) { + this.worker.postMessage(value); + } + } +} diff --git a/libs/workers/src/worker/consts/worker-fn-template.ts b/libs/workers/src/worker/consts/worker-fn-template.ts new file mode 100644 index 000000000..0d01e13e1 --- /dev/null +++ b/libs/workers/src/worker/consts/worker-fn-template.ts @@ -0,0 +1,22 @@ +// throw an error using the `setTimeout` function +// because web worker doesn't emit ErrorEvent from promises +export const WORKER_BLANK_FN = ` +function(fn){ + function isFunction(type){ + return type === 'function'; + } + + self.addEventListener('message', function(e) { + var result = fn.call(null, e.data); + if (result && [typeof result.then, typeof result.catch].every(isFunction)){ + result + .then(postMessage) + .catch(function(error) { + setTimeout(function(){throw error}, 0) + }) + } else { + postMessage(result); + } + }) +} +`; diff --git a/libs/workers/src/worker/operators/to-data.ts b/libs/workers/src/worker/operators/to-data.ts new file mode 100644 index 000000000..0db7e1a23 --- /dev/null +++ b/libs/workers/src/worker/operators/to-data.ts @@ -0,0 +1,7 @@ +import {OperatorFunction} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {TypedMessageEvent} from '../types/typed-message-event'; + +export function toData(): OperatorFunction, T> { + return map, T>(({data}) => data); +} diff --git a/libs/workers/src/worker/pipes/worker.pipe.ts b/libs/workers/src/worker/pipes/worker.pipe.ts new file mode 100644 index 000000000..36a9fd720 --- /dev/null +++ b/libs/workers/src/worker/pipes/worker.pipe.ts @@ -0,0 +1,41 @@ +import {OnDestroy, Pipe, PipeTransform} from '@angular/core'; +import {Observable} from 'rxjs'; +import {WebWorker} from '../classes/web-worker'; +import {toData} from '../operators/to-data'; +import {WorkerFunction} from '../types/worker-function'; + +@Pipe({ + name: 'waWorker', +}) +export class WorkerPipe implements PipeTransform, OnDestroy { + private fn!: WorkerFunction; + private worker!: WebWorker; + private observer!: Observable; + + transform(value: T, fn: WorkerFunction): Observable { + if (this.fn !== fn) { + this.terminateWorker(); + this.initNewWorker(fn); + } + + this.worker.postMessage(value); + + return this.observer; + } + + ngOnDestroy(): void { + this.terminateWorker(); + } + + private terminateWorker() { + if (this.worker) { + this.worker.terminate(); + } + } + + private initNewWorker(fn: WorkerFunction) { + this.fn = fn; + this.worker = WebWorker.fromFunction(fn); + this.observer = this.worker.pipe(toData()); + } +} diff --git a/libs/workers/src/worker/types/typed-message-event.ts b/libs/workers/src/worker/types/typed-message-event.ts new file mode 100644 index 000000000..492e03b8d --- /dev/null +++ b/libs/workers/src/worker/types/typed-message-event.ts @@ -0,0 +1,3 @@ +export interface TypedMessageEvent extends MessageEvent { + data: T; +} diff --git a/libs/workers/src/worker/types/worker-function.ts b/libs/workers/src/worker/types/worker-function.ts new file mode 100644 index 000000000..854e5557d --- /dev/null +++ b/libs/workers/src/worker/types/worker-function.ts @@ -0,0 +1 @@ +export type WorkerFunction = (data: T) => R | Promise; diff --git a/libs/workers/src/worker/worker.module.ts b/libs/workers/src/worker/worker.module.ts new file mode 100644 index 000000000..e188f5197 --- /dev/null +++ b/libs/workers/src/worker/worker.module.ts @@ -0,0 +1,8 @@ +import {NgModule} from '@angular/core'; +import {WorkerPipe} from './pipes/worker.pipe'; + +@NgModule({ + declarations: [WorkerPipe], + exports: [WorkerPipe], +}) +export class WorkerModule {} diff --git a/libs/workers/test.ts b/libs/workers/test.ts new file mode 100644 index 000000000..a9cc62b9e --- /dev/null +++ b/libs/workers/test.ts @@ -0,0 +1,23 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files +import 'zone.js/dist/zone'; +import 'zone.js/dist/zone-testing'; + +import {getTestBed} from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +declare const require: any; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), +); + +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); + +// And load the modules. +context.keys().map(context); diff --git a/libs/workers/tests/web-worker.spec.ts b/libs/workers/tests/web-worker.spec.ts new file mode 100644 index 000000000..822b7c225 --- /dev/null +++ b/libs/workers/tests/web-worker.spec.ts @@ -0,0 +1,113 @@ +import {Observable} from 'rxjs'; +import {take} from 'rxjs/operators'; +import {TypedMessageEvent} from '../src/worker/types/typed-message-event'; +import {WebWorker} from '../src/worker/classes/web-worker'; + +// it is needed to ignore web worker errors +window.onerror = () => {}; + +describe('WebWorker', () => { + it('should fail if a worker is not available', async () => { + const OriginalWorker = Worker; + + delete (window as any).Worker; + + const worker = WebWorker.fromFunction(d => d); + + expect(() => worker.terminate()).not.toThrow(); + expect(() => worker.postMessage()).not.toThrow(); + + await expectAsync(worker.toPromise()).toBeRejected(); + + (window as any).Worker = OriginalWorker; + }); + + it('should create worker from a function', () => { + const worker = WebWorker.fromFunction(d => d); + + expect(worker instanceof WebWorker).toEqual(true); + expect((worker as any).worker instanceof Worker).toEqual(true); + }); + + it('should trigger an error if URL was not found', async () => { + const worker = new WebWorker('some/wrong/url'); + + await expectAsync(worker.toPromise()).toBeRejected(); + }); + + it('should resolve the last value before completing', async () => { + const worker = WebWorker.fromFunction((data: string) => Promise.resolve(data)); + + const promise = worker + .pipe(source => { + return new Observable(subscriber => { + source.subscribe({ + next({data}: TypedMessageEvent) { + (source as WebWorker).terminate(); + subscriber.next(data); + subscriber.complete(); + }, + }); + }); + }) + .toPromise(); + + worker.postMessage('a'); + worker.postMessage('b'); + expect(await promise).toEqual('a'); + }); + + it('should run a worker and return a correct data', async () => { + const workerPromise: Promise> = WebWorker.execute< + string, + string + >(data => Promise.resolve().then(() => data), 'some data'); + + expect((await workerPromise).data).toEqual('some data'); + }, 10000); + + it('should create worker', async () => { + const thread = WebWorker.fromFunction(data => + Promise.resolve(data), + ); + + const workerPromise = thread.pipe(take(1)).toPromise(); + + thread.postMessage('some data'); + + expect((await workerPromise)?.data).toEqual('some data'); + }, 10000); + + it('should fail if an inner promise is rejected', async () => { + const worker = WebWorker.fromFunction(() => + Promise.reject('reason'), + ); + + worker.postMessage(); + + expect(await worker.toPromise().catch(err => err.message)).toEqual( + 'Uncaught reason', + ); + }); + + it('should close all subscriptions, if the worker was terminated', async () => { + const worker = WebWorker.fromFunction(() => 'some data'); + + const subscriptions = [ + worker.subscribe(), + worker.subscribe(), + worker.subscribe(), + ]; + + worker.terminate(); + expect(subscriptions.map(s => s.closed)).toEqual([true, true, true]); + }); + + it("shouldn't throw any errors, if the worker was terminated twice", async () => { + const worker = WebWorker.fromFunction(() => 'some data'); + + worker.terminate(); + worker.terminate(); + expect(await worker.toPromise()).toBeUndefined(); + }); +}); diff --git a/libs/workers/tests/worker.pipe.spec.ts b/libs/workers/tests/worker.pipe.spec.ts new file mode 100644 index 000000000..4372932d3 --- /dev/null +++ b/libs/workers/tests/worker.pipe.spec.ts @@ -0,0 +1,49 @@ +import {take} from 'rxjs/operators'; +import {WorkerPipe} from '../src/worker/pipes/worker.pipe'; + +describe('WorkerPipe', () => { + let pipe: WorkerPipe; + + beforeEach(() => { + pipe = new WorkerPipe(); + }); + + it('should emit the first value', async () => { + const result = await pipe + .transform('a', data => data) + .pipe(take(1)) + .toPromise(); + + expect(await result).toEqual('a'); + }); + + it('should return the same worker for the same function', async () => { + const workerFn = (data: unknown) => data; + + const worker = await pipe.transform('a', workerFn); + const theSameWorker = await pipe.transform('a', workerFn); + + expect(worker).toEqual(theSameWorker); + }); + + it('should return a different worker for a different function', async () => { + const worker = await pipe.transform('a', (data: unknown) => data); + const differentWorker = await pipe.transform('a', (data: unknown) => data); + + expect(worker).not.toEqual(differentWorker); + }); + + it('should terminate a previous worker', async () => { + const worker = await pipe.transform('a', (data: unknown) => data); + + await pipe.transform('a', (data: unknown) => data); + await expectAsync(worker.toPromise()).toBeResolved(); + }); + + it('should terminate a worker then a pipe is destroyed', async () => { + const worker = await pipe.transform('a', (data: unknown) => data); + + pipe.ngOnDestroy(); + await expectAsync(worker.toPromise()).toBeResolved(); + }); +}); diff --git a/libs/workers/tsconfig.spec.json b/libs/workers/tsconfig.spec.json new file mode 100644 index 000000000..8e7067ed2 --- /dev/null +++ b/libs/workers/tsconfig.spec.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.spec.json", + "include": ["**/*.spec.ts", "./test.ts", "**/*.d.ts"], + "files": ["./test.ts"] +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 2ccc5ff8c..db547a771 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -22,6 +22,7 @@ "@ng-web-apis/permissions": ["./dist/permissions"], "@ng-web-apis/intersection-observer": ["./dist/intersection-observer"], "@ng-web-apis/midi": ["./dist/midi"], + "@ng-web-apis/workers": ["./dist/workers"], "@ng-web-apis/resize-observer": ["./dist/resize-observer"] } } diff --git a/tsconfig.json b/tsconfig.json index 155456cb5..f8f98feda 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -74,6 +74,7 @@ "@ng-web-apis/intersection-observer": ["./libs/intersection-observer/src/index.ts"], "@ng-web-apis/midi": ["./libs/midi/src/index.ts"], "@ng-web-apis/storage": ["./libs/storage/src/index.ts"], + "@ng-web-apis/workers": ["./libs/workers/src/index.ts"], "@ng-web-apis/resize-observer": ["./libs/resize-observer/src/index.ts"] } },