├── .betterer.results ├── .betterer.ts ├── .browserslistrc ├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierignore ├── .releaserc ├── LICENSE ├── README.md ├── angular.json ├── cypress.json ├── generate-test-files.sh ├── karma.conf.js ├── package.json ├── prettier.config.js ├── projects └── observable-webworker │ ├── .eslintrc.json │ ├── README.md │ ├── karma.conf.js │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── from-worker-pool.spec.ts │ │ ├── from-worker-pool.ts │ │ ├── from-worker.spec.ts │ │ ├── from-worker.ts │ │ ├── observable-worker.decorator.spec.ts │ │ ├── observable-worker.decorator.ts │ │ ├── observable-worker.types.ts │ │ ├── run-worker.spec.ts │ │ └── run-worker.ts │ ├── public-api.ts │ └── test.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json ├── src ├── app │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── doc │ │ └── async-work.worker.ts │ ├── file-hash.worker.ts │ ├── google-charts.service.ts │ ├── hash-worker.types.ts │ ├── multiple-worker-pool │ │ ├── log-line │ │ │ ├── log-line.component.html │ │ │ ├── log-line.component.scss │ │ │ ├── log-line.component.spec.ts │ │ │ └── log-line.component.ts │ │ ├── multiple-worker-pool.component.html │ │ ├── multiple-worker-pool.component.scss │ │ ├── multiple-worker-pool.component.spec.ts │ │ └── multiple-worker-pool.component.ts │ └── single-worker │ │ ├── single-worker.component.html │ │ ├── single-worker.component.scss │ │ ├── single-worker.component.spec.ts │ │ └── single-worker.component.ts ├── assets │ └── .gitkeep ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── readme │ ├── hello-legacy-webpack.ts │ ├── hello.ts │ ├── hello.worker.ts │ ├── transferable.main.ts │ ├── worker-pool-hash.worker.ts │ └── worker-pool.main.ts ├── styles.scss └── test.ts ├── test-files └── .gitkeep ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json ├── tsconfig.worker.json └── yarn.lock /.betterer.results: -------------------------------------------------------------------------------- 1 | // BETTERER RESULTS V1. 2 | exports[`stricter compilation`] = { 3 | timestamp: 1705378026461, 4 | value: `{ 5 | "src/app/google-charts.service.ts:1308226900": [ 6 | [3, 29, 15, "Could not find a declaration file for module \'google-charts\'. \'./node_modules/google-charts/dist/googleCharts.js\' implicitly has an \'any\' type.\\n Try \`npm i --save-dev @types/google-charts\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'google-charts\';\`", "2535818334"] 7 | ], 8 | "src/app/multiple-worker-pool/log-line/log-line.component.ts:1179533322": [ 9 | [10, 11, 7, "Property \'message\' has no initializer and is not definitely assigned in the constructor.", "1236122734"], 10 | [13, 9, 5, "Property \'color\' has no initializer and is not definitely assigned in the constructor.", "176948952"] 11 | ], 12 | "src/app/multiple-worker-pool/multiple-worker-pool.component.ts:816086637": [ 13 | [90, 9, 14, "Type \'Observable<{ millisSinceLast: number | null; file?: string | undefined; timestamp: Date; message: string; thread: Thread; fileEventType: FileHashEvent | null; }[]>\' is not assignable to type \'Observable\'.\\n Type \'{ millisSinceLast: number | null; file?: string | undefined; timestamp: Date; message: string; thread: Thread; fileEventType: FileHashEvent | null; }[]\' is not assignable to type \'HashWorkerMessage[]\'.\\n Type \'{ millisSinceLast: number | null; file?: string | undefined; timestamp: Date; message: string; thread: Thread; fileEventType: FileHashEvent | null; }\' is not assignable to type \'HashWorkerMessage\'.\\n Types of property \'millisSinceLast\' are incompatible.\\n Type \'number | null\' is not assignable to type \'number | undefined\'.\\n Type \'null\' is not assignable to type \'number | undefined\'.", "463576723"] 14 | ] 15 | }` 16 | }; -------------------------------------------------------------------------------- /.betterer.ts: -------------------------------------------------------------------------------- 1 | import { typescriptBetterer } from '@betterer/typescript'; 2 | 3 | export default { 4 | 'stricter compilation': typescriptBetterer('./tsconfig.json', { 5 | strict: true, 6 | }), 7 | }; 8 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["projects/**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "parserOptions": { 8 | "project": ["tsconfig.json", "e2e/tsconfig.json"], 9 | "createDefaultProgram": true 10 | }, 11 | "extends": ["plugin:@angular-eslint/recommended", "plugin:@angular-eslint/template/process-inline-templates"], 12 | "rules": { 13 | "@angular-eslint/component-selector": [ 14 | "error", 15 | { 16 | "prefix": "lib", 17 | "style": "kebab-case", 18 | "type": "element" 19 | } 20 | ], 21 | "@angular-eslint/directive-selector": [ 22 | "error", 23 | { 24 | "prefix": "lib", 25 | "style": "camelCase", 26 | "type": "attribute" 27 | } 28 | ] 29 | } 30 | }, 31 | { 32 | "files": ["*.html"], 33 | "extends": ["plugin:@angular-eslint/template/recommended"], 34 | "rules": {} 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions docs 2 | # https://help.github.com/en/articles/about-github-actions 3 | # https://help.github.com/en/articles/workflow-syntax-for-github-actions 4 | name: CI 5 | 6 | on: [push] 7 | 8 | jobs: 9 | build: 10 | 11 | # Machine environment: 12 | # https://help.github.com/en/articles/software-in-virtual-environments-for-github-actions#ubuntu-1804-lts 13 | # We specify the Node.js version manually below, and use versioned Chrome from Puppeteer. 14 | runs-on: ubuntu-22.04 15 | 16 | steps: 17 | - uses: actions/checkout@v1 18 | - name: Use Node.js 16x 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: 16 22 | - name: Install dependencies 23 | run: yarn --frozen-lockfile --non-interactive --no-progress 24 | - name: Lint Demo 25 | run: yarn demo:lint:check 26 | - name: Format check 27 | run: yarn prettier:check 28 | - name: Check Readme 29 | run: yarn readme:check 30 | - name: Test 31 | run: yarn lib:test:ci 32 | - name: Coverage 33 | uses: codecov/codecov-action@v2 34 | with: 35 | fail_ci_if_error: true # optional (default = false) 36 | directory: coverage 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | - name: Build 39 | run: yarn lib:build:prod 40 | - name: Incremental code quality checking 41 | run: yarn betterer 42 | - name: Build Demo 43 | if: contains('refs/heads/master', github.ref) 44 | run: | 45 | yarn run demo:build:prod --progress=false --base-href "https://cloudnc.github.io/observable-webworker/" 46 | yarn lib:build:prod 47 | - name: Copy built README into dist 48 | run: rm dist/observable-webworker/README.md && cp README.md dist/observable-webworker 49 | - name: Copy LICENSE into dist 50 | run: cp LICENSE dist/observable-webworker 51 | - name: Release 52 | if: contains('refs/heads/master refs/heads/next', github.ref) 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 56 | run: npx semantic-release 57 | - name: Deploy 58 | if: contains('refs/heads/master', github.ref) 59 | uses: peaceiris/actions-gh-pages@v3 60 | with: 61 | github_token: ${{ secrets.GITHUB_TOKEN }} 62 | publish_dir: ./dist/observable-webworker-demo 63 | -------------------------------------------------------------------------------- /.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 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events.json 15 | speed-measure-plugin.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.angular/cache 36 | /.sass-cache 37 | /connect.lock 38 | /coverage 39 | /libpeerconnection.log 40 | npm-debug.log 41 | yarn-error.log 42 | testem.log 43 | /typings 44 | 45 | # System Files 46 | .DS_Store 47 | Thumbs.db 48 | 49 | test-files/*.txt 50 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | dist 3 | node_modules 4 | coverage 5 | .history 6 | .angular 7 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "pkgRoot": "dist/observable-webworker" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Zak Henry 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Observable Webworker 2 | 3 | Simple API for using [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) with [RxJS](https://rxjs-dev.firebaseapp.com/guide/overview) observables 4 | 5 | [![Strict TypeScript Checked](https://badgen.net/badge/TS/Strict "Strict TypeScript Checked")](https://www.typescriptlang.org) 6 | [![npm version](https://badge.fury.io/js/observable-webworker.svg)](https://www.npmjs.com/package/observable-webworker) 7 | [![Build Status](https://github.com/cloudnc/observable-webworker/workflows/CI/badge.svg)](https://github.com/cloudnc/observable-webworker/actions) 8 | [![codecov](https://codecov.io/gh/cloudnc/observable-webworker/branch/master/graph/badge.svg)](https://codecov.io/gh/cloudnc/observable-webworker) 9 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](https://commitizen.github.io/cz-cli/) 10 | [![License](https://img.shields.io/github/license/cloudnc/observable-webworker)](https://raw.githubusercontent.com/cloudnc/observable-webworker/master/LICENSE) 11 | ![npm peer dependency version](https://img.shields.io/npm/dependency-version/observable-webworker/peer/rxjs) 12 | 13 | [![NPM](https://nodei.co/npm/observable-webworker.png?compact=true)](https://nodei.co/npm/observable-webworker/) 14 | 15 | # Features 16 | 17 | - Simple `fromWorker` function from main thread side 18 | - Fully RxJS interfaces allowing both main thread and worker thread streaming 19 | - Error handling across the thread boundaries is propagated 20 | - Under the hood `materialize` and `dematerialize` is used as a robust transport of streaming errors 21 | - Automatic handling of worker termination on main thread unsubscription of observable 22 | - Framework agnostic - while the demo uses Angular, the only dependency is rxjs, so React, Vue or plain old JS is 23 | completely compatible 24 | - Fully compatible with [Webpack worker-plugin](https://github.com/GoogleChromeLabs/worker-plugin) 25 | - Therefore compatible with [Angular webworker bundling](https://angular.io/guide/web-worker) which uses this 26 | - Class interface based worker creation (should be familiar API for Angular developers) 27 | - Unopinionated on stream switching behavior, feel free to use `mergeMap`, `switchMap` or `exhaustMap` in your worker if 28 | the input stream contains multiple items that generate their own stream of results 29 | - Built in interfaces for handling [`Transferable`](https://developer.mozilla.org/en-US/docs/Web/API/Transferable) parts 30 | of message payloads so large binaries can transferred efficiently without copying - See [Transferable](#transferable) 31 | section for usage 32 | - Automatic destruction of worker on unsubscription of output stream, this allows for smart cancelling of computation 33 | using `switchMap` operator, or parallelisation of computation with `mergeMap` 34 | - [Worker Pool strategy](#worker-pool-strategy) - maximise the throughput of units of work by utilising all cores on the host machine 35 | 36 | ## Demo 37 | https://cloudnc.github.io/observable-webworker 38 | 39 | ## Tutorial 40 | https://dev.to/zakhenry/observable-webworkers-with-angular-8-4k6 41 | 42 | ## Articles 43 | * [Observable Web Workers, a deep dive into a realistic use case](https://dev.to/zakhenry/observable-web-workers-a-deep-dive-into-a-realistic-use-case-4042) 44 | * [Parallel computation in the browser with observable webworkers](https://dev.to/zakhenry/parallel-computation-in-the-browser-with-observable-webworkers-hci) 45 | 46 | ## Install 47 | 48 | Install the [npm package](https://www.npmjs.com/package/observable-webworker): `observable-webworker` 49 | 50 | ```sh 51 | # with npm 52 | npm install observable-webworker 53 | # or with yarn 54 | yarn add observable-webworker 55 | ``` 56 | 57 | ## Usage 58 | 59 | ### Quickstart 60 | 61 | #### Main Thread 62 | 63 | 💡 Take note! The webworker construction syntax differs for different version of webpack: 64 | 65 | #### Webpack < 5 (deprecated) 66 | 67 | ```ts 68 | // src/readme/hello-legacy-webpack.ts 69 | 70 | import { fromWorker } from 'observable-webworker'; 71 | import { of } from 'rxjs'; 72 | 73 | const input$ = of('Hello from main thread'); 74 | 75 | fromWorker(() => new Worker('./hello.worker', { type: 'module' }), input$).subscribe(message => { 76 | console.log(message); // Outputs 'Hello from webworker' 77 | }); 78 | 79 | ``` 80 | #### Webpack 5 81 | 82 | ```ts 83 | // src/readme/hello.ts#L2-L12 84 | 85 | import { fromWorker } from 'observable-webworker'; 86 | import { of } from 'rxjs'; 87 | 88 | const input$ = of('Hello from main thread'); 89 | 90 | fromWorker( 91 | () => new Worker(new URL('./hello.worker', import.meta.url), { type: 'module' }), 92 | input$, 93 | ).subscribe(message => { 94 | console.log(message); // Outputs 'Hello from webworker' 95 | }); 96 | ``` 97 | 98 | #### Worker Thread 99 | 100 | ```ts 101 | // src/readme/hello.worker.ts 102 | 103 | import { DoWork, runWorker } from 'observable-webworker'; 104 | import { Observable } from 'rxjs'; 105 | import { map } from 'rxjs/operators'; 106 | 107 | export class HelloWorker implements DoWork { 108 | public work(input$: Observable): Observable { 109 | return input$.pipe( 110 | map(message => { 111 | console.log(message); // outputs 'Hello from main thread' 112 | return `Hello from webworker`; 113 | }), 114 | ); 115 | } 116 | } 117 | 118 | runWorker(HelloWorker); 119 | 120 | ``` 121 | 122 | #### Decorator deprecation notice 123 | 124 | Future versions of webpack (Webpack 5) minify webworkers overly aggressively, causing 125 | the `@ObservableWorker()` decorator pattern to no longer function. This decorator 126 | has now been deprecated, and will be removed in the next major version of this library. 127 | 128 | To migrate from decorators, simply remove the decorator, and invoke the `runWorker` 129 | with your class passed as argument (see example above). 130 | 131 | Make sure you **don't forget to remove the decorator** when you add the `runWorker(...)` 132 | function, otherwise the worker will be run twice, each acting on any message sent. 133 | 134 | ## Transferable 135 | 136 | If either your input or output (or both!) streams are passing large messages to or from the worker, it is highly 137 | recommended to use message types that implement the [Transferable](https://developer.mozilla.org/en-US/docs/Web/API/Transferable) 138 | interface (`ArrayBuffer`, `MessagePort`, `ImageBitmap`). 139 | 140 | Bear in mind that when transferring a message to a webworker that the main thread relinquishes ownership of the data. 141 | 142 | Recommended reading: 143 | 144 | - https://developer.mozilla.org/en-US/docs/Web/API/Transferable 145 | - https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage 146 | 147 | To use `Transferable`s with observable-worker, a slightly more complex interface is provided for both sides of the 148 | main/worker thread. 149 | 150 | If the main thread is transferring `Transferable`s _to the worker_, simply add a callback to the `fromWorker` function 151 | call to select which elements of the input stream are transferable. 152 | 153 | 154 | ```ts 155 | // src/readme/transferable.main.ts#L7-L11 156 | 157 | return fromWorker( 158 | () => new Worker(new URL('./transferable.worker', import.meta.url), { type: 'module' }), 159 | input$, 160 | input => [input], 161 | ); 162 | ``` 163 | 164 | If the worker is transferring `Transferable`s _to the main thread_ simply implement `DoTransferableWork`, which will 165 | require you to add an additional method `selectTransferables?(output: O): Transferable[];` which you implement to select 166 | which elements of the output object are `Transferable`. 167 | 168 | Both strategies are compatible with each other, so if for example you're computing the hash of a large `ArrayBuffer` in 169 | a worker, you would only need to use add the transferable selector callback in the main thread in order to mark the 170 | `ArrayBuffer` as being transferable in the input. The library will handle the rest, and you can just use `DoWork` in the 171 | worker thread, as the return type `string` is not `Transferable`. 172 | 173 | ## Worker Pool Strategy 174 | 175 | If you have a large amount of work that needs to be done, you can use the `fromWorkerPool` function to automatically 176 | manage a pool of workers to allow true concurrency of work, distributed evenly across all available cores. 177 | 178 | The worker pool strategy has the following features 179 | * Work can be provided as either `Observable`, `Array`, or `Iterable` 180 | * Concurrency is limited to `navigation.hardwareConcurrency - 1` to keep the main core free. 181 | * This is a configurable option if you know you already have other workers running 182 | * Workers are only created when there is need for them (work is available) 183 | * Workers are terminated when there is no more work, freeing up threads for other processes 184 | * for `Observable`, work is considered remaining while the observable is not completed 185 | * for `Array`, work remains while there are items in the array 186 | * for `Iterable`, work remains while the iterator is not `result.done` 187 | * Workers are kept running while work remains, preventing unnecessary downloading of the worker script 188 | * Custom observable flattening operator can be passed, allowing for custom behaviour such as correlating the output 189 | order with input order 190 | * default operator is `mergeAll()`, which means the output from the webworker(s) is output as soon as available 191 | 192 | 193 | ### Example 194 | 195 | In this simple example, we have a function that receives an array of files and returns an observable of the MD5 sum 196 | hashes of those files. For simplicity, we're passing the primitives back and forth, however in reality you are likely to 197 | want to construct your own interface to define the messages being passed to and from the worker. 198 | 199 | #### Main Thread 200 | 201 | ```ts 202 | // src/readme/worker-pool.main.ts 203 | 204 | import { Observable } from 'rxjs'; 205 | import { fromWorkerPool } from 'observable-webworker'; 206 | 207 | export function computeHashes(files: File[]): Observable { 208 | return fromWorkerPool( 209 | () => new Worker(new URL('./worker-pool-hash.worker', import.meta.url), { type: 'module' }), 210 | files, 211 | ); 212 | } 213 | 214 | ``` 215 | 216 | #### Worker Thread 217 | 218 | ```ts 219 | // src/readme/worker-pool-hash.worker.ts 220 | 221 | import * as md5 from 'js-md5'; 222 | import { DoWorkUnit, runWorker } from 'observable-webworker'; 223 | import { Observable } from 'rxjs'; 224 | import { map } from 'rxjs/operators'; 225 | 226 | export class WorkerPoolHashWorker implements DoWorkUnit { 227 | public workUnit(input: File): Observable { 228 | return this.readFileAsArrayBuffer(input).pipe(map(arrayBuffer => md5(arrayBuffer))); 229 | } 230 | 231 | private readFileAsArrayBuffer(blob: Blob): Observable { 232 | return new Observable(observer => { 233 | if (!(blob instanceof Blob)) { 234 | observer.error(new Error('`blob` must be an instance of File or Blob.')); 235 | return; 236 | } 237 | 238 | const reader = new FileReader(); 239 | 240 | reader.onerror = err => observer.error(err); 241 | reader.onload = () => observer.next(reader.result as ArrayBuffer); 242 | reader.onloadend = () => observer.complete(); 243 | 244 | reader.readAsArrayBuffer(blob); 245 | 246 | return () => reader.abort(); 247 | }); 248 | } 249 | } 250 | 251 | runWorker(WorkerPoolHashWorker); 252 | 253 | ``` 254 | 255 | Note here that the worker class `implements DoWorkUnit`. This is different to before where we implemented 256 | `DoWork` which had the slightly more complex signature of inputting an observable and outputting one. 257 | 258 | If using the `fromWorkerPool` strategy, you must only implement `DoWorkUnit` as it relies on the completion of the 259 | returned observable to indicate that the unit of work is finished processing, and the next unit of work can be 260 | transferred to the worker. 261 | 262 | Commonly, a worker that implements `DoWorkUnit` only needs to return a single value, so you may instead return a `Promise` 263 | from the `workUnit` method. 264 | 265 | ```ts 266 | // src/app/doc/async-work.worker.ts#L7-L14 267 | 268 | export class FactorizationWorker implements DoWorkUnit { 269 | public async workUnit(input: number): Promise { 270 | return factorize(input); 271 | } 272 | } 273 | 274 | runWorker(FactorizationWorker); 275 | 276 | ``` 277 | 278 | --- 279 | 280 | Are you using observable-webworker? [Tell us about it!](https://github.com/cloudnc/observable-webworker/discussions/69) We'd love to hear about the weird and wonderful ways developers are working with streaming workers. 281 | 282 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "observable-webworker-demo": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/observable-webworker-demo", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": "src/polyfills.ts", 24 | "tsConfig": "tsconfig.app.json", 25 | "assets": ["src/favicon.ico", "src/assets"], 26 | "styles": ["src/styles.scss"], 27 | "scripts": [], 28 | "webWorkerTsConfig": "tsconfig.worker.json", 29 | "vendorChunk": true, 30 | "extractLicenses": false, 31 | "buildOptimizer": false, 32 | "sourceMap": true, 33 | "optimization": false, 34 | "namedChunks": true 35 | }, 36 | "configurations": { 37 | "production": { 38 | "fileReplacements": [ 39 | { 40 | "replace": "src/environments/environment.ts", 41 | "with": "src/environments/environment.prod.ts" 42 | } 43 | ], 44 | "optimization": true, 45 | "outputHashing": "all", 46 | "namedChunks": false, 47 | "extractLicenses": true, 48 | "vendorChunk": false, 49 | "buildOptimizer": true, 50 | "budgets": [ 51 | { 52 | "type": "initial", 53 | "maximumWarning": "2mb", 54 | "maximumError": "5mb" 55 | }, 56 | { 57 | "type": "anyComponentStyle", 58 | "maximumWarning": "6kb" 59 | } 60 | ] 61 | } 62 | }, 63 | "defaultConfiguration": "" 64 | }, 65 | "serve": { 66 | "builder": "@angular-devkit/build-angular:dev-server", 67 | "options": { 68 | "browserTarget": "observable-webworker-demo:build" 69 | }, 70 | "configurations": { 71 | "production": { 72 | "browserTarget": "observable-webworker-demo:build:production" 73 | } 74 | } 75 | }, 76 | "extract-i18n": { 77 | "builder": "@angular-devkit/build-angular:extract-i18n", 78 | "options": { 79 | "browserTarget": "observable-webworker-demo:build" 80 | } 81 | }, 82 | "test": { 83 | "builder": "@angular-devkit/build-angular:karma", 84 | "options": { 85 | "main": "src/test.ts", 86 | "polyfills": "src/polyfills.ts", 87 | "tsConfig": "tsconfig.spec.json", 88 | "karmaConfig": "karma.conf.js", 89 | "assets": ["src/favicon.ico", "src/assets"], 90 | "styles": ["src/styles.scss"], 91 | "scripts": [] 92 | } 93 | } 94 | } 95 | }, 96 | "observable-webworker": { 97 | "projectType": "library", 98 | "root": "projects/observable-webworker", 99 | "sourceRoot": "projects/observable-webworker/src", 100 | "prefix": "lib", 101 | "architect": { 102 | "build": { 103 | "builder": "@angular-devkit/build-angular:ng-packagr", 104 | "options": { 105 | "tsConfig": "projects/observable-webworker/tsconfig.lib.json", 106 | "project": "projects/observable-webworker/ng-package.json" 107 | }, 108 | "configurations": { 109 | "production": { 110 | "tsConfig": "projects/observable-webworker/tsconfig.lib.prod.json" 111 | } 112 | } 113 | }, 114 | "test": { 115 | "builder": "@angular-devkit/build-angular:karma", 116 | "options": { 117 | "main": "projects/observable-webworker/src/test.ts", 118 | "tsConfig": "projects/observable-webworker/tsconfig.spec.json", 119 | "karmaConfig": "projects/observable-webworker/karma.conf.js" 120 | } 121 | }, 122 | "lint": { 123 | "builder": "@angular-eslint/builder:lint", 124 | "options": { 125 | "lintFilePatterns": ["projects/observable-webworker/**/*.ts", "projects/observable-webworker/**/*.html"] 126 | } 127 | } 128 | } 129 | } 130 | }, 131 | "cli": { 132 | "schematicCollections": ["@angular-eslint/schematics"] 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignoreTestFiles": ["!**/*.e2e.ts", "**/*.util.e2e.ts"], 3 | "integrationFolder": "src", 4 | "baseUrl": "http://localhost:4765", 5 | "video": false, 6 | "viewportWidth": 1500, 7 | "viewportHeight": 1400 8 | } 9 | -------------------------------------------------------------------------------- /generate-test-files.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | rm -rf test-files/* 4 | 5 | COUNT=${1:-10} 6 | MAXSIZE_MB=${2:-400} 7 | 8 | for i in $(seq 1 $COUNT) 9 | do 10 | SIZE=$(( ( RANDOM % $MAXSIZE_MB ) + 1 )) 11 | 12 | WORD1=$( shuf -n1 /usr/share/dict/words ) 13 | WORD2=$( shuf -n1 /usr/share/dict/words ) 14 | 15 | truncate -s "$SIZE"M "test-files/"$i"-$WORD1-$WORD2-$SIZE""MB.txt" 16 | done 17 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-coverage'), 11 | require('karma-chrome-launcher'), 12 | require('karma-jasmine-html-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma'), 14 | ], 15 | client: { 16 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | reporters: ['progress', 'kjhtml', 'coverage'], 19 | coverageReporter: { 20 | // specify a common output directory 21 | dir: require('path').join(__dirname, '../../coverage/observable-webworker'), 22 | reporters: [ 23 | // reporters not supporting the `file` property 24 | { type: 'html', subdir: 'report-html' }, 25 | // { type: 'lcov', subdir: 'report-lcov' }, 26 | // reporters supporting the `file` property, use `subdir` to directly 27 | // output them in the `dir` directory 28 | // { type: 'cobertura', subdir: '.', file: 'cobertura.txt' }, 29 | { type: 'lcovonly', subdir: '.', file: 'lcov.info' }, 30 | // { type: 'teamcity', subdir: '.', file: 'teamcity.txt' }, 31 | // { type: 'text', subdir: '.', file: 'text.txt' }, 32 | { type: 'text-summary', subdir: '.', file: 'text-summary.txt' }, 33 | ], 34 | }, 35 | port: 9876, 36 | colors: true, 37 | logLevel: config.LOG_INFO, 38 | autoWatch: true, 39 | browsers: ['ChromeHeadlessNoSandbox'], 40 | customLaunchers: { 41 | ChromeHeadlessNoSandbox: { 42 | base: 'ChromeHeadless', 43 | flags: ['--no-sandbox'], 44 | }, 45 | }, 46 | singleRun: false, 47 | restartOnFileChange: true, 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "observable-webworker", 3 | "version": "0.0.0-development", 4 | "license": "MIT", 5 | "scripts": { 6 | "------------------ CORE COMMANDS -----------------": "", 7 | "ng": "ng", 8 | "------------------ LINT COMMANDS -----------------": "", 9 | "prettier": "prettier", 10 | "prettier:base": "yarn run prettier \"**/*.{js,json,scss,ts,html}\"", 11 | "prettier:write": "yarn run prettier:base --write", 12 | "prettier:check": "yarn run prettier:base --list-different", 13 | "------------------ DEMO COMMANDS -----------------": "", 14 | "demo:start": "yarn run ng serve", 15 | "demo:build:base": "yarn run ng build", 16 | "demo:build:prod": "yarn run demo:build:base --configuration=production", 17 | "demo:test": "yarn run ng test", 18 | "demo:test:e2e:watch": "yarn run cy open", 19 | "demo:test:e2e:ci": "yarn run cy run", 20 | "demo:lint:check": "yarn run ng lint", 21 | "demo:lint:fix": "yarn run demo:lint:check --fix", 22 | "------------------ LIB COMMANDS -----------------": "", 23 | "lib:build:prod": "yarn run ng build --project observable-webworker --configuration=production", 24 | "lib:build:watch": "yarn run lib:build:prod --watch", 25 | "lib:test:watch": "yarn run ng test --project observable-webworker", 26 | "lib:test:ci": "yarn run ng test --project observable-webworker --watch false --code-coverage", 27 | "------------------ RELEASE COMMANDS ------------------": "", 28 | "semantic-release": "semantic-release", 29 | "readme:build": "embedme README.md", 30 | "readme:check": "yarn readme:build && ! git status | grep README.md || (echo 'You must commit build and commit changes to README.md!' && exit 1)", 31 | "report-coverage": "codecov", 32 | "------------------ QUICK COMMANDS ------------------": "", 33 | "lint:fix": "yarn demo:lint:fix && yarn prettier:write", 34 | "test": "yarn lib:test:watch --code-coverage", 35 | "start": "yarn demo:start", 36 | "commit": "git add . && git-cz", 37 | "betterer": "betterer", 38 | "lint": "ng lint" 39 | }, 40 | "dependencies": { 41 | "@angular/animations": "^16.2.12", 42 | "@angular/common": "^16.2.12", 43 | "@angular/compiler": "^16.2.12", 44 | "@angular/core": "^16.2.12", 45 | "@angular/forms": "^16.2.12", 46 | "@angular/platform-browser": "^16.2.12", 47 | "@angular/platform-browser-dynamic": "^16.2.12", 48 | "@angular/router": "^16.2.12", 49 | "google-charts": "2.0.0", 50 | "js-md5": "0.7.3", 51 | "rxjs": "~6.6.3", 52 | "tslib": "^2.3.1", 53 | "zone.js": "~0.13.3" 54 | }, 55 | "devDependencies": { 56 | "@angular-devkit/build-angular": "^16.2.11", 57 | "@angular-eslint/builder": "^16.0.0", 58 | "@angular-eslint/eslint-plugin": "^16.0.0", 59 | "@angular-eslint/eslint-plugin-template": "^16.0.0", 60 | "@angular-eslint/schematics": "^16.0.0", 61 | "@angular-eslint/template-parser": "^16.0.0", 62 | "@angular/cli": "^16.2.11", 63 | "@angular/compiler-cli": "^16.2.12", 64 | "@angular/language-service": "^16.2.12", 65 | "@betterer/cli": "^1.1.3", 66 | "@betterer/typescript": "^1.1.2", 67 | "@types/google.visualization": "0.0.48", 68 | "@types/jasmine": "~3.6.0", 69 | "@types/jasminewd2": "~2.0.3", 70 | "@types/js-md5": "^0.4.2", 71 | "@types/node": "^16.0.0", 72 | "@typescript-eslint/eslint-plugin": "^5.3.0", 73 | "@typescript-eslint/parser": "^5.3.0", 74 | "commitizen": "4.0.3", 75 | "cypress": "3.4.1", 76 | "cz-conventional-changelog": "2.1.0", 77 | "embedme": "1.6.0", 78 | "eslint": "^8.2.0", 79 | "jasmine-core": "~3.8.0", 80 | "jasmine-spec-reporter": "~5.0.0", 81 | "karma": "~6.3.11", 82 | "karma-chrome-launcher": "~3.1.0", 83 | "karma-coverage": "2.1.0", 84 | "karma-jasmine": "~4.0.0", 85 | "karma-jasmine-html-reporter": "^1.5.0", 86 | "ng-packagr": "16.2.3", 87 | "prettier": "2.2.1", 88 | "semantic-release": "^17.2.3", 89 | "ts-node": "~7.0.0", 90 | "typescript": "~5.1.6" 91 | }, 92 | "repository": { 93 | "type": "git", 94 | "url": "https://github.com/cloudnc/observable-webworker.git" 95 | }, 96 | "config": { 97 | "commitizen": { 98 | "path": "./node_modules/cz-conventional-changelog" 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: true, 6 | singleQuote: true, 7 | trailingComma: 'all', 8 | bracketSpacing: true, 9 | arrowParens: 'avoid', 10 | rangeStart: 0, 11 | rangeEnd: Infinity, 12 | requirePragma: false, 13 | insertPragma: false, 14 | proseWrap: 'preserve', 15 | htmlWhitespaceSensitivity: 'ignore', 16 | }; 17 | -------------------------------------------------------------------------------- /projects/observable-webworker/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "parserOptions": { 8 | "project": [ 9 | "projects/observable-webworker/tsconfig.lib.json", 10 | "projects/observable-webworker/tsconfig.spec.json" 11 | ], 12 | "createDefaultProgram": true 13 | }, 14 | "rules": { 15 | "@angular-eslint/directive-selector": [ 16 | "error", 17 | { 18 | "type": "attribute", 19 | "prefix": "lib", 20 | "style": "camelCase" 21 | } 22 | ], 23 | "@angular-eslint/component-selector": [ 24 | "error", 25 | { 26 | "type": "element", 27 | "prefix": "lib", 28 | "style": "kebab-case" 29 | } 30 | ] 31 | } 32 | }, 33 | { 34 | "files": ["*.html"], 35 | "rules": {} 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /projects/observable-webworker/README.md: -------------------------------------------------------------------------------- 1 | # ObservableWorker 2 | 3 | This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.0.0. 4 | 5 | ## Code scaffolding 6 | 7 | Run `ng generate component component-name --project observable-webworker` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project observable-webworker`. 8 | 9 | > Note: Don't forget to add `--project observable-webworker` or else it will be added to the default project in your `angular.json` file. 10 | 11 | ## Build 12 | 13 | Run `ng build observable-webworker` to build the project. The build artifacts will be stored in the `dist/` directory. 14 | 15 | ## Publishing 16 | 17 | After building your library with `ng build observable-webworker`, go to the dist folder `cd dist/observable-webworker` and run `npm publish`. 18 | 19 | ## Running unit tests 20 | 21 | Run `ng test observable-webworker` to execute the unit tests via [Karma](https://karma-runner.github.io). 22 | 23 | ## Further help 24 | 25 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 26 | -------------------------------------------------------------------------------- /projects/observable-webworker/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-coverage'), 11 | require('karma-chrome-launcher'), 12 | require('karma-jasmine-html-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma'), 14 | ], 15 | client: { 16 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../../coverage/observable-webworker'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true, 22 | }, 23 | reporters: ['progress', 'kjhtml', 'coverage'], 24 | coverageReporter: { 25 | // specify a common output directory 26 | dir: require('path').join(__dirname, '../../coverage/observable-webworker'), 27 | reporters: [ 28 | // reporters not supporting the `file` property 29 | { type: 'html', subdir: 'report-html' }, 30 | // { type: 'lcov', subdir: 'report-lcov' }, 31 | // reporters supporting the `file` property, use `subdir` to directly 32 | // output them in the `dir` directory 33 | // { type: 'cobertura', subdir: '.', file: 'cobertura.txt' }, 34 | { type: 'lcovonly', subdir: '.', file: 'lcov.info' }, 35 | // { type: 'teamcity', subdir: '.', file: 'teamcity.txt' }, 36 | // { type: 'text', subdir: '.', file: 'text.txt' }, 37 | { type: 'text-summary', subdir: '.', file: 'text-summary.txt' }, 38 | ], 39 | }, 40 | port: 9876, 41 | colors: true, 42 | logLevel: config.LOG_INFO, 43 | autoWatch: true, 44 | browsers: ['ChromeHeadlessNoSandbox'], 45 | customLaunchers: { 46 | ChromeHeadlessNoSandbox: { 47 | base: 'ChromeHeadless', 48 | flags: ['--no-sandbox'], 49 | }, 50 | }, 51 | singleRun: false, 52 | restartOnFileChange: true, 53 | }); 54 | }; 55 | -------------------------------------------------------------------------------- /projects/observable-webworker/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/observable-webworker", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /projects/observable-webworker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "observable-webworker", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "peerDependencies": { 6 | "rxjs": ">=6.4.0" 7 | }, 8 | "keywords": [ 9 | "Rx", 10 | "RxJS", 11 | "ReactiveX", 12 | "ReactiveExtensions", 13 | "Streams", 14 | "Observables", 15 | "Observable", 16 | "Stream", 17 | "ES6", 18 | "ES2015", 19 | "Worker", 20 | "Webworker", 21 | "Webpack", 22 | "Multithread" 23 | ], 24 | "dependencies": { 25 | "tslib": "^2.0.0" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/cloudnc/observable-webworker.git" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /projects/observable-webworker/src/lib/from-worker-pool.spec.ts: -------------------------------------------------------------------------------- 1 | import { fakeAsync, tick } from '@angular/core/testing'; 2 | import { Observable, Subject, Subscription } from 'rxjs'; 3 | import { Notification } from 'rxjs/internal/Notification'; 4 | import { reduce } from 'rxjs/operators'; 5 | import { fromWorkerPool } from './from-worker-pool'; 6 | 7 | describe('fromWorkerPool', () => { 8 | let stubbedWorkers: Worker[]; 9 | 10 | let workerFactorySpy: jasmine.Spy; 11 | 12 | beforeEach(() => { 13 | stubbedWorkers = []; 14 | workerFactorySpy = jasmine.createSpy('workerFactorySpy'); 15 | let stubWorkerIndex = 0; 16 | workerFactorySpy.and.callFake(() => { 17 | const stubWorker = jasmine.createSpyObj(`stubWorker${stubWorkerIndex++}`, ['postMessage', 'terminate']); 18 | stubbedWorkers.push(stubWorker); 19 | return stubWorker; 20 | }); 21 | }); 22 | 23 | describe('with observable input', () => { 24 | let input$: Subject; 25 | let stubbedWorkerStream: Observable; 26 | 27 | beforeEach(() => { 28 | input$ = new Subject(); 29 | stubbedWorkerStream = fromWorkerPool(workerFactorySpy, input$); 30 | }); 31 | 32 | it('constructs one worker for one piece of work', () => { 33 | expect(workerFactorySpy).not.toHaveBeenCalled(); 34 | 35 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 36 | const sub = stubbedWorkerStream.subscribe(subscriptionSpy); 37 | 38 | expect(workerFactorySpy).not.toHaveBeenCalled(); 39 | expect(subscriptionSpy).not.toHaveBeenCalled(); 40 | 41 | input$.next(1); 42 | 43 | expect(workerFactorySpy).toHaveBeenCalledWith(0); 44 | 45 | sub.unsubscribe(); 46 | }); 47 | 48 | it('constructs as many workers as concurrency allows when the input exceeds the output', () => { 49 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 50 | const sub = stubbedWorkerStream.subscribe(subscriptionSpy); 51 | 52 | for (let i = 0; i < 20; i++) { 53 | input$.next(i); 54 | } 55 | 56 | expect(workerFactorySpy).toHaveBeenCalledTimes(navigator.hardwareConcurrency - 1); 57 | 58 | expect(stubbedWorkers[0].postMessage).toHaveBeenCalledWith(jasmine.objectContaining({ kind: 'N', value: 0 })); 59 | 60 | sub.unsubscribe(); 61 | }); 62 | 63 | it('does not send input close notification to ensure the workers are kept alive', () => { 64 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 65 | const sub = stubbedWorkerStream.subscribe(subscriptionSpy); 66 | 67 | input$.next(1); 68 | 69 | expect(stubbedWorkers[0].postMessage).not.toHaveBeenCalledWith(jasmine.objectContaining({ kind: 'C' })); 70 | 71 | sub.unsubscribe(); 72 | }); 73 | 74 | it('shuts down workers when subscriber unsubscribes', () => { 75 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 76 | const sub = stubbedWorkerStream.subscribe(subscriptionSpy); 77 | 78 | for (let i = 0; i < 20; i++) { 79 | input$.next(i); 80 | } 81 | 82 | for (let i = 0; i < navigator.hardwareConcurrency - 1; i++) { 83 | expect(stubbedWorkers[i].terminate).not.toHaveBeenCalled(); 84 | } 85 | 86 | sub.unsubscribe(); 87 | 88 | for (let i = 0; i < navigator.hardwareConcurrency - 1; i++) { 89 | expect(stubbedWorkers[i].terminate).toHaveBeenCalledTimes(1); 90 | } 91 | }); 92 | 93 | it('does not shut down workers when outstanding work remains', () => { 94 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 95 | const sub = stubbedWorkerStream.subscribe(subscriptionSpy); 96 | 97 | for (let i = 0; i < 20; i++) { 98 | input$.next(i); 99 | } 100 | 101 | for (let i = 0; i < navigator.hardwareConcurrency - 1; i++) { 102 | const stubWorker = stubbedWorkers[i]; 103 | 104 | stubWorker.onmessage!( 105 | new MessageEvent('message', { 106 | data: new Notification('N', i), 107 | }), 108 | ); 109 | 110 | expect(subscriptionSpy).toHaveBeenCalledWith(i); 111 | 112 | stubWorker.onmessage!( 113 | new MessageEvent('message', { 114 | data: new Notification('C'), 115 | }), 116 | ); 117 | 118 | expect(stubbedWorkers[i].terminate).not.toHaveBeenCalled(); 119 | } 120 | 121 | sub.unsubscribe(); 122 | }); 123 | }); 124 | 125 | describe('with array input', () => { 126 | it('should be able to use array as input', () => { 127 | const workerCount = 7; 128 | 129 | const input = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; 130 | 131 | const testWorkerStream$ = fromWorkerPool(workerFactorySpy, input, { workerCount }); 132 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 133 | const sub = testWorkerStream$ 134 | .pipe( 135 | reduce((out: number[], res: number) => { 136 | out.push(res); 137 | return out; 138 | }, []), 139 | ) 140 | .subscribe(subscriptionSpy); 141 | 142 | for (const i of input) { 143 | const stubWorker = stubbedWorkers[i % workerCount]; 144 | 145 | stubWorker.onmessage!( 146 | new MessageEvent('message', { 147 | data: new Notification('N', i), 148 | }), 149 | ); 150 | 151 | stubWorker.onmessage!( 152 | new MessageEvent('message', { 153 | data: new Notification('C'), 154 | }), 155 | ); 156 | } 157 | 158 | expect(subscriptionSpy).toHaveBeenCalledWith([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 159 | 160 | sub.unsubscribe(); 161 | }); 162 | }); 163 | 164 | describe('with iterator input', () => { 165 | it('should be able to use iterator/generator as input', () => { 166 | const workerCount = 7; 167 | 168 | function* generator() { 169 | yield 0; 170 | yield 1; 171 | yield 2; 172 | yield 3; 173 | yield 4; 174 | yield 5; 175 | yield 6; 176 | yield 7; 177 | yield 8; 178 | yield 9; 179 | } 180 | 181 | const input = generator(); 182 | 183 | const testWorkerStream$ = fromWorkerPool(workerFactorySpy, input, { workerCount }); 184 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 185 | const sub = testWorkerStream$ 186 | .pipe( 187 | reduce((out: number[], res: number) => { 188 | out.push(res); 189 | return out; 190 | }, []), 191 | ) 192 | .subscribe(subscriptionSpy); 193 | 194 | for (const i of generator()) { 195 | const stubWorker = stubbedWorkers[i % workerCount]; 196 | 197 | stubWorker.onmessage!( 198 | new MessageEvent('message', { 199 | data: new Notification('N', i), 200 | }), 201 | ); 202 | 203 | stubWorker.onmessage!( 204 | new MessageEvent('message', { 205 | data: new Notification('C'), 206 | }), 207 | ); 208 | } 209 | 210 | expect(workerFactorySpy).toHaveBeenCalledTimes(7); 211 | expect(subscriptionSpy).toHaveBeenCalledWith([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 212 | 213 | sub.unsubscribe(); 214 | }); 215 | }); 216 | 217 | describe('with undefined navigator.hardwareConcurrency', () => { 218 | it('runs a default fallback number of workers', () => { 219 | spyOnProperty(window.navigator, 'hardwareConcurrency').and.returnValue(undefined); 220 | 221 | const input = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; 222 | 223 | const testWorkerStream$ = fromWorkerPool(workerFactorySpy, input); 224 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 225 | const sub = testWorkerStream$.subscribe(subscriptionSpy); 226 | 227 | expect(workerFactorySpy).toHaveBeenCalledTimes(3); 228 | 229 | sub.unsubscribe(); 230 | }); 231 | 232 | it('runs a configured fallback number of workers', () => { 233 | spyOnProperty(window.navigator, 'hardwareConcurrency').and.returnValue(undefined); 234 | 235 | const input = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; 236 | 237 | const testWorkerStream$ = fromWorkerPool(workerFactorySpy, input, { fallbackWorkerCount: 2 }); 238 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 239 | const sub = testWorkerStream$.subscribe(subscriptionSpy); 240 | 241 | expect(workerFactorySpy).toHaveBeenCalledTimes(2); 242 | 243 | sub.unsubscribe(); 244 | }); 245 | }); 246 | 247 | describe('output strategy', () => { 248 | it('[default] outputs results as they are available', fakeAsync(() => { 249 | const workerCount = 7; 250 | 251 | const input = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; 252 | 253 | const testWorkerStream$ = fromWorkerPool(workerFactorySpy, input, { workerCount }); 254 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 255 | const sub = testWorkerStream$ 256 | .pipe( 257 | reduce((out: number[], res: number) => { 258 | out.push(res); 259 | return out; 260 | }, []), 261 | ) 262 | .subscribe(subscriptionSpy); 263 | 264 | for (let i = 0; i < input.length; i++) { 265 | setTimeout(() => { 266 | const stubWorker = stubbedWorkers[i % workerCount]; 267 | 268 | stubWorker.onmessage!( 269 | new MessageEvent('message', { 270 | data: new Notification('N', input[i]), 271 | }), 272 | ); 273 | 274 | stubWorker.onmessage!( 275 | new MessageEvent('message', { 276 | data: new Notification('C'), 277 | }), 278 | ); 279 | }, 10 - i); // output each result in successively less time for each value 280 | } 281 | 282 | tick(10); 283 | 284 | expect(workerFactorySpy).toHaveBeenCalledTimes(7); 285 | expect(subscriptionSpy).toHaveBeenCalledWith([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]); 286 | 287 | sub.unsubscribe(); 288 | })); 289 | 290 | it('outputs results as specified by custom passed flattening operator', fakeAsync(() => { 291 | const workerCount = 7; 292 | 293 | const input = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; 294 | 295 | const operatorSpy = jasmine.createSpy('subscriptionSpy'); 296 | 297 | function customOperator(outerObservable$: Observable>): Observable { 298 | return new Observable(subscriber => { 299 | const innerSubs: Subscription[] = []; 300 | 301 | const outerSub = outerObservable$.subscribe(innerObservable$ => { 302 | innerSubs.push( 303 | innerObservable$.subscribe(value => { 304 | subscriber.next(value * 2); 305 | operatorSpy(); 306 | }), 307 | ); 308 | }); 309 | 310 | return () => { 311 | innerSubs.forEach(s => s.unsubscribe()); 312 | outerSub.unsubscribe(); 313 | }; 314 | }); 315 | } 316 | 317 | const testWorkerStream$ = fromWorkerPool(workerFactorySpy, input, { 318 | workerCount, 319 | flattenOperator: customOperator, 320 | }); 321 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 322 | const sub = testWorkerStream$ 323 | .pipe( 324 | reduce((out: number[], res: number) => { 325 | out.push(res); 326 | return out; 327 | }, []), 328 | ) 329 | .subscribe(subscriptionSpy); 330 | 331 | for (let i = 0; i < input.length; i++) { 332 | setTimeout(() => { 333 | const stubWorker = stubbedWorkers[i % workerCount]; 334 | 335 | stubWorker.onmessage!( 336 | new MessageEvent('message', { 337 | data: new Notification('N', input[i]), 338 | }), 339 | ); 340 | 341 | stubWorker.onmessage!( 342 | new MessageEvent('message', { 343 | data: new Notification('C'), 344 | }), 345 | ); 346 | }, 10 - i); // output each result in successively less time for each value 347 | } 348 | 349 | tick(10); 350 | 351 | expect(subscriptionSpy).toHaveBeenCalledWith([18, 16, 14, 12, 10, 8, 6, 4, 2, 0]); 352 | expect(operatorSpy).toHaveBeenCalledTimes(10); 353 | 354 | sub.unsubscribe(); 355 | })); 356 | }); 357 | }); 358 | -------------------------------------------------------------------------------- /projects/observable-webworker/src/lib/from-worker-pool.ts: -------------------------------------------------------------------------------- 1 | import { concat, NEVER, Observable, ObservableInput, of, Subject, zip } from 'rxjs'; 2 | import { finalize, map, mergeAll, tap } from 'rxjs/operators'; 3 | import { fromWorker } from './from-worker'; 4 | 5 | interface LazyWorker { 6 | factory: () => Worker; 7 | terminate: () => void; 8 | processing: boolean; 9 | index: number; 10 | } 11 | 12 | export interface WorkerPoolOptions { 13 | workerCount?: number; 14 | fallbackWorkerCount?: number; 15 | flattenOperator?: (input$: Observable>) => Observable; 16 | selectTransferables?: (input: I) => Transferable[]; 17 | } 18 | 19 | export function fromWorkerPool( 20 | workerConstructor: (index: number) => Worker, 21 | workUnitIterator: ObservableInput, 22 | options?: WorkerPoolOptions, 23 | ): Observable { 24 | const { 25 | // eslint-disable-next-line no-undef-init 26 | selectTransferables = undefined, 27 | workerCount = navigator.hardwareConcurrency ? navigator.hardwareConcurrency - 1 : null, 28 | fallbackWorkerCount = 3, 29 | flattenOperator = mergeAll(), 30 | } = options || {}; 31 | 32 | return new Observable(resultObserver => { 33 | const idleWorker$$: Subject = new Subject(); 34 | 35 | let completed = 0; 36 | let sent = 0; 37 | let finished = false; 38 | 39 | const lazyWorkers: LazyWorker[] = Array.from({ 40 | length: workerCount !== null ? workerCount : fallbackWorkerCount, 41 | }).map((_, index) => { 42 | let cachedWorker: Worker | null = null; 43 | return { 44 | factory() { 45 | if (!cachedWorker) { 46 | cachedWorker = workerConstructor(index); 47 | } 48 | return cachedWorker; 49 | }, 50 | terminate() { 51 | if (!this.processing && cachedWorker) { 52 | cachedWorker.terminate(); 53 | } 54 | }, 55 | processing: false, 56 | index, 57 | }; 58 | }); 59 | 60 | const processor$ = zip(idleWorker$$, workUnitIterator).pipe( 61 | tap(([worker]) => { 62 | sent++; 63 | worker.processing = true; 64 | }), 65 | finalize(() => { 66 | idleWorker$$.complete(); 67 | finished = true; 68 | lazyWorkers.forEach(worker => worker.terminate()); 69 | }), 70 | map( 71 | ([worker, unitWork]): Observable => { 72 | // input should not complete to ensure the worker doesn't send back completion notifications when work unit is 73 | // processed, otherwise these would cause the fromWorker to unsubscribe from the result. 74 | const input$ = concat(of(unitWork), NEVER); 75 | // const input$ = of(unitWork); 76 | return fromWorker(() => worker.factory(), input$, selectTransferables, { 77 | terminateOnComplete: false, 78 | }).pipe( 79 | finalize(() => { 80 | completed++; 81 | 82 | worker.processing = false; 83 | 84 | if (!finished) { 85 | idleWorker$$.next(worker); 86 | } else { 87 | worker.terminate(); 88 | } 89 | 90 | if (finished && completed === sent) { 91 | resultObserver.complete(); 92 | } 93 | }), 94 | ); 95 | }, 96 | ), 97 | flattenOperator, 98 | ); 99 | 100 | const sub = processor$.subscribe(resultObserver); 101 | 102 | lazyWorkers.forEach(w => idleWorker$$.next(w)); 103 | 104 | return () => sub.unsubscribe(); 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /projects/observable-webworker/src/lib/from-worker.spec.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Observer, of, Subject } from 'rxjs'; 2 | import { Notification } from 'rxjs/internal/Notification'; 3 | import { fromWorker } from './from-worker'; 4 | 5 | describe('fromWorker', () => { 6 | let input$: Subject; 7 | 8 | let stubWorker: Worker; 9 | 10 | let workerFactorySpy: jasmine.Spy<() => Worker>; 11 | 12 | let stubbedWorkerStream: Observable; 13 | 14 | beforeEach(() => { 15 | input$ = new Subject(); 16 | 17 | stubWorker = jasmine.createSpyObj('stubWorker', ['postMessage', 'terminate']); 18 | 19 | workerFactorySpy = jasmine.createSpy('workerFactorySpy'); 20 | workerFactorySpy.and.returnValue(stubWorker); 21 | 22 | stubbedWorkerStream = fromWorker(workerFactorySpy, input$); 23 | }); 24 | 25 | it('should construct a worker and post input notification messages to it', () => { 26 | expect(workerFactorySpy).not.toHaveBeenCalled(); 27 | 28 | const subscriptionSpy = jasmine.createSpy('subscriptionSpy'); 29 | const sub = stubbedWorkerStream.subscribe(subscriptionSpy); 30 | 31 | expect(workerFactorySpy).toHaveBeenCalled(); 32 | expect(stubWorker.postMessage).not.toHaveBeenCalled(); 33 | expect(subscriptionSpy).not.toHaveBeenCalled(); 34 | 35 | input$.next(1); 36 | 37 | expect(stubWorker.postMessage).toHaveBeenCalledWith(jasmine.objectContaining({ kind: 'N', value: 1 })); 38 | 39 | input$.next(2); 40 | 41 | expect(stubWorker.postMessage).toHaveBeenCalledWith(jasmine.objectContaining({ kind: 'N', value: 2 })); 42 | 43 | input$.complete(); 44 | 45 | expect(stubWorker.postMessage).toHaveBeenCalledWith(jasmine.objectContaining({ kind: 'C' })); 46 | 47 | sub.unsubscribe(); 48 | 49 | expect(stubWorker.terminate).toHaveBeenCalled(); 50 | }); 51 | 52 | it('should pass unhandled streamed errors on to the worker', () => { 53 | stubbedWorkerStream.subscribe(); 54 | 55 | input$.error('oops!'); 56 | 57 | expect(stubWorker.postMessage).toHaveBeenCalledWith(jasmine.objectContaining({ kind: 'E', error: 'oops!' })); 58 | }); 59 | 60 | it('should assign methods to the worker events that materialize into observable output, terminating the worker on completion', () => { 61 | const subscriptionNextSpy = jasmine.createSpy('subscriptionNextSpy'); 62 | const subscriptionCompleteSpy = jasmine.createSpy('subscriptionCompleteSpy'); 63 | const sub = stubbedWorkerStream.subscribe({ next: subscriptionNextSpy, complete: subscriptionCompleteSpy }); 64 | 65 | expect(stubWorker.onmessage).toEqual(jasmine.any(Function)); 66 | expect(stubWorker.onerror).toEqual(jasmine.any(Function)); 67 | 68 | expect(subscriptionNextSpy).not.toHaveBeenCalled(); 69 | 70 | stubWorker.onmessage!( 71 | new MessageEvent('message', { 72 | data: new Notification('N', 1), 73 | }), 74 | ); 75 | 76 | expect(subscriptionNextSpy).toHaveBeenCalledWith(1); 77 | 78 | stubWorker.onmessage!( 79 | new MessageEvent('message', { 80 | data: new Notification('C'), 81 | }), 82 | ); 83 | 84 | expect(subscriptionCompleteSpy).toHaveBeenCalled(); 85 | expect(stubWorker.terminate).toHaveBeenCalled(); 86 | expect(sub.closed).toBe(true); 87 | }); 88 | 89 | it('should propagate errors from the worker stream', () => { 90 | const subscriptionErrorSpy = jasmine.createSpy('subscriptionErrorSpy'); 91 | const sub = stubbedWorkerStream.subscribe({ error: subscriptionErrorSpy }); 92 | 93 | stubWorker.onmessage!( 94 | new MessageEvent('message', { 95 | data: new Notification('E', undefined, 'Nope!'), 96 | }), 97 | ); 98 | 99 | expect(subscriptionErrorSpy).toHaveBeenCalledWith('Nope!'); 100 | 101 | expect(stubWorker.terminate).toHaveBeenCalled(); 102 | expect(sub.closed).toBe(true); 103 | }); 104 | 105 | it('should propagate worker system errors', () => { 106 | const subscriptionErrorSpy = jasmine.createSpy('subscriptionErrorSpy'); 107 | const sub = stubbedWorkerStream.subscribe({ error: subscriptionErrorSpy }); 108 | 109 | stubWorker.onerror!(new ErrorEvent('error', { message: 'Argh!' })); 110 | 111 | expect(subscriptionErrorSpy).toHaveBeenCalledWith( 112 | jasmine.objectContaining({ 113 | message: 'Argh!', 114 | }), 115 | ); 116 | 117 | expect(stubWorker.terminate).toHaveBeenCalled(); 118 | expect(sub.closed).toBe(true); 119 | }); 120 | 121 | it('should propagate browser errors', () => { 122 | const subscriptionErrorSpy = jasmine.createSpy('subscriptionErrorSpy'); 123 | 124 | const testErrorStream = fromWorker(() => { 125 | throw new Error('Oops!'); 126 | }, input$); 127 | 128 | const sub = testErrorStream.subscribe({ error: subscriptionErrorSpy }); 129 | 130 | expect(subscriptionErrorSpy).toHaveBeenCalledWith( 131 | jasmine.objectContaining({ 132 | message: 'Oops!', 133 | }), 134 | ); 135 | 136 | expect(sub.closed).toBe(true); 137 | }); 138 | 139 | it('should construct multiple workers for multiple subscribers', () => { 140 | const sub1 = stubbedWorkerStream.subscribe(); 141 | const sub2 = stubbedWorkerStream.subscribe(); 142 | 143 | expect(workerFactorySpy).toHaveBeenCalledTimes(2); 144 | 145 | sub1.unsubscribe(); 146 | 147 | expect(sub1.closed).toBe(true); 148 | expect(sub2.closed).toBe(false); 149 | expect(stubWorker.terminate).toHaveBeenCalledTimes(1); 150 | 151 | sub2.unsubscribe(); 152 | expect(sub2.closed).toBe(true); 153 | expect(stubWorker.terminate).toHaveBeenCalledTimes(2); 154 | }); 155 | 156 | it('identifies transferables and passes them through to the worker', () => { 157 | const subscriptionSpy = jasmine.createSpyObj>('subscriptionSpy', ['next', 'complete', 'error']); 158 | 159 | const testValue = new Int8Array(1); 160 | testValue[0] = 99; 161 | 162 | const testTransferableStream = fromWorker(workerFactorySpy, of(testValue), input => [ 163 | input.buffer, 164 | ]); 165 | 166 | const sub = testTransferableStream.subscribe(subscriptionSpy); 167 | 168 | expect(stubWorker.postMessage).toHaveBeenCalledWith(jasmine.objectContaining({ kind: 'N', value: testValue }), [ 169 | testValue.buffer, 170 | ] as any); 171 | 172 | stubWorker.onmessage!( 173 | new MessageEvent('message', { 174 | data: new Notification('N', 1), 175 | }), 176 | ); 177 | 178 | stubWorker.onmessage!( 179 | new MessageEvent('message', { 180 | data: new Notification('C'), 181 | }), 182 | ); 183 | 184 | expect(subscriptionSpy.next).toHaveBeenCalledWith(1); 185 | 186 | expect(sub.closed).toBe(true); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /projects/observable-webworker/src/lib/from-worker.ts: -------------------------------------------------------------------------------- 1 | import { Notification, Observable, Observer, Subscription } from 'rxjs'; 2 | import { dematerialize, map, materialize, tap } from 'rxjs/operators'; 3 | import { WorkerMessageNotification } from './observable-worker.types'; 4 | 5 | export interface WorkerOptions { 6 | terminateOnComplete: boolean; 7 | } 8 | 9 | export function fromWorker( 10 | workerFactory: () => Worker, 11 | input$: Observable, 12 | selectTransferables?: (input: Input) => Transferable[], 13 | options: WorkerOptions = { terminateOnComplete: true }, 14 | ): Observable { 15 | return new Observable((responseObserver: Observer>) => { 16 | let worker: Worker; 17 | let subscription: Subscription; 18 | 19 | try { 20 | worker = workerFactory(); 21 | worker.onmessage = (ev: WorkerMessageNotification) => responseObserver.next(ev.data); 22 | worker.onerror = (ev: ErrorEvent) => responseObserver.error(ev); 23 | 24 | subscription = input$ 25 | .pipe( 26 | materialize(), 27 | tap(input => { 28 | if (selectTransferables && input.hasValue) { 29 | const transferables = selectTransferables(input.value as Input); 30 | worker.postMessage(input, transferables); 31 | } else { 32 | worker.postMessage(input); 33 | } 34 | }), 35 | ) 36 | .subscribe(); 37 | } catch (error) { 38 | responseObserver.error(error); 39 | } 40 | 41 | return () => { 42 | if (subscription) { 43 | subscription.unsubscribe(); 44 | } 45 | if (worker && options.terminateOnComplete) { 46 | worker.terminate(); 47 | } 48 | }; 49 | }).pipe( 50 | map(({ kind, value, error }) => new Notification(kind, value, error)), 51 | dematerialize(), 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /projects/observable-webworker/src/lib/observable-worker.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { Notification, Observable } from 'rxjs'; 2 | import { take } from 'rxjs/operators'; 3 | import { ObservableWorker } from './observable-worker.decorator'; 4 | import { DoWork, WorkerMessageNotification } from './observable-worker.types'; 5 | 6 | describe('@ObservableWorker (deprecated, remove in next major version)', () => { 7 | it('should automatically run the worker', () => { 8 | const postMessageSpy = spyOn(window, 'postMessage'); 9 | postMessageSpy.calls.reset(); 10 | 11 | @ObservableWorker() 12 | class TestWorker implements DoWork { 13 | public work(input$: Observable): Observable { 14 | return input$.pipe(take(1)); 15 | } 16 | } 17 | 18 | const nextEvent: WorkerMessageNotification = new MessageEvent('message', { 19 | data: new Notification('N', 0), 20 | }); 21 | 22 | self.dispatchEvent(nextEvent); 23 | 24 | expect(postMessageSpy).toHaveBeenCalledWith( 25 | jasmine.objectContaining({ 26 | kind: 'N', 27 | value: 0, 28 | }), 29 | ); 30 | 31 | expect(postMessageSpy).toHaveBeenCalledWith( 32 | jasmine.objectContaining({ 33 | kind: 'C', 34 | }), 35 | ); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /projects/observable-webworker/src/lib/observable-worker.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ObservableWorkerConstructor, runWorker } from './run-worker'; 2 | 3 | /** 4 | * @deprecated - use the `runWorker(YourWorkerClass)` strategy instead, for 5 | * compatibility with future webpack versions, and a slightly smaller bundle 6 | * @see https://github.com/cloudnc/observable-webworker#decorator-deprecation-notice 7 | */ 8 | export function ObservableWorker() { 9 | return (workerConstructor: ObservableWorkerConstructor): void => { 10 | runWorker(workerConstructor); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /projects/observable-webworker/src/lib/observable-worker.types.ts: -------------------------------------------------------------------------------- 1 | import { Notification, Observable } from 'rxjs'; 2 | 3 | /** @internal */ 4 | export interface GenericWorkerMessage

{ 5 | payload: P; 6 | transferables?: Transferable[]; 7 | } 8 | 9 | /** @internal */ 10 | export interface WorkerMessageNotification extends MessageEvent { 11 | data: Notification; 12 | } 13 | 14 | export interface DoWorkUnit { 15 | workUnit(input: I): Observable | PromiseLike; 16 | selectTransferables?(output: O): Transferable[]; 17 | } 18 | 19 | export interface DoWork { 20 | work(input$: Observable): Observable; 21 | selectTransferables?(output: O): Transferable[]; 22 | } 23 | 24 | // same as DoWork, but selectTransferables is required 25 | export interface DoTransferableWork extends DoWork { 26 | selectTransferables(output: O): Transferable[]; 27 | } 28 | 29 | // same as DoWorkUnit, but selectTransferables is required 30 | export interface DoTransferableWorkUnit extends DoWorkUnit { 31 | selectTransferables(output: O): Transferable[]; 32 | } 33 | -------------------------------------------------------------------------------- /projects/observable-webworker/src/lib/run-worker.spec.ts: -------------------------------------------------------------------------------- 1 | import { fakeAsync, tick } from '@angular/core/testing'; 2 | import { BehaviorSubject, Notification, Observable, of } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | import { 5 | DoTransferableWork, 6 | DoTransferableWorkUnit, 7 | DoWork, 8 | DoWorkUnit, 9 | WorkerMessageNotification, 10 | } from './observable-worker.types'; 11 | import { runWorker, workerIsTransferableType, workerIsUnitType } from './run-worker'; 12 | 13 | describe('workerIsTransferableType', () => { 14 | it('should identify a worker as being able to map transferables', () => { 15 | class TestWorkerTransferable implements DoTransferableWork { 16 | public selectTransferables(output: number): Transferable[] { 17 | return []; 18 | } 19 | 20 | public work(input$: Observable): Observable { 21 | return of(1); 22 | } 23 | } 24 | 25 | class TestWorkerNotTransferable implements DoWork { 26 | public work(input$: Observable): Observable { 27 | return of(1); 28 | } 29 | } 30 | 31 | expect(workerIsTransferableType(new TestWorkerTransferable())).toBe(true); 32 | expect(workerIsTransferableType(new TestWorkerNotTransferable())).toBe(false); 33 | }); 34 | }); 35 | 36 | describe('workerIsUnitType', () => { 37 | it('should identify a worker as being able to do work units', () => { 38 | class TestWorkerUnit implements DoWorkUnit { 39 | public workUnit(input: number): Observable { 40 | return of(1); 41 | } 42 | } 43 | 44 | class TestWorkerNotUnit implements DoWork { 45 | public work(input$: Observable): Observable { 46 | return of(1); 47 | } 48 | } 49 | 50 | expect(workerIsUnitType(new TestWorkerUnit())).toBe(true); 51 | expect(workerIsUnitType(new TestWorkerNotUnit())).toBe(false); 52 | }); 53 | }); 54 | 55 | describe('runWorker', () => { 56 | it('should read messages from self.message event emitter and process work and send results back to postmessage', () => { 57 | const postMessageSpy = spyOn(window, 'postMessage'); 58 | 59 | class TestWorkerUnit implements DoWorkUnit { 60 | public workUnit(input: number): Observable { 61 | return of(input * 2); 62 | } 63 | } 64 | 65 | const sub = runWorker(TestWorkerUnit); 66 | 67 | const event: WorkerMessageNotification = new MessageEvent('message', { 68 | data: new Notification('N', 11), 69 | }); 70 | 71 | self.dispatchEvent(event); 72 | 73 | expect(postMessageSpy).toHaveBeenCalledWith( 74 | jasmine.objectContaining({ 75 | kind: 'N', 76 | value: 22, 77 | }), 78 | ); 79 | 80 | sub.unsubscribe(); 81 | }); 82 | 83 | it('should pass outbound transferables to the postMessage call', () => { 84 | const postMessageSpy = spyOn(window, 'postMessage'); 85 | 86 | class TestWorkerUnitTransferable implements DoTransferableWorkUnit { 87 | public workUnit(input: Int8Array): Observable { 88 | for (let i = 0; i < input.length; i++) { 89 | input[i] *= 3; 90 | } 91 | 92 | return of(input); 93 | } 94 | 95 | public selectTransferables(output: Int8Array): Transferable[] { 96 | return [output.buffer]; 97 | } 98 | } 99 | 100 | const sub = runWorker(TestWorkerUnitTransferable); 101 | 102 | const payload = new Int8Array(3); 103 | payload[0] = 1; 104 | payload[1] = 2; 105 | payload[2] = 3; 106 | 107 | const expected = new Int8Array(3); 108 | expected[0] = 3; 109 | expected[1] = 6; 110 | expected[2] = 9; 111 | 112 | const event: WorkerMessageNotification = new MessageEvent('message', { 113 | data: new Notification('N', payload), 114 | }); 115 | 116 | self.dispatchEvent(event); 117 | 118 | expect(postMessageSpy).toHaveBeenCalledWith( 119 | jasmine.objectContaining({ 120 | kind: 'N', 121 | value: payload, 122 | }), 123 | [expected.buffer] as any, 124 | ); 125 | 126 | sub.unsubscribe(); 127 | }); 128 | 129 | // https://github.com/cloudnc/observable-webworker/issues/116 130 | it('should complete the notification stream when the worker completes', () => { 131 | const postMessageSpy = spyOn(window, 'postMessage'); 132 | postMessageSpy.calls.reset(); 133 | 134 | class TestWorker implements DoWork { 135 | public work(input$: Observable): Observable { 136 | // here nothing should keep the subscription alive when input$ completes 137 | return input$.pipe(map(input => input * 2)); 138 | } 139 | } 140 | 141 | const sub = runWorker(TestWorker); 142 | 143 | const notificationEvent: WorkerMessageNotification = new MessageEvent('message', { 144 | data: new Notification('N', 10), 145 | }); 146 | 147 | self.dispatchEvent(notificationEvent); 148 | 149 | expect(postMessageSpy).toHaveBeenCalledWith( 150 | jasmine.objectContaining({ 151 | kind: 'N', 152 | value: 20, 153 | }), 154 | ); 155 | 156 | const completeEvent: WorkerMessageNotification = new MessageEvent('message', { 157 | data: new Notification('C'), 158 | }); 159 | 160 | self.dispatchEvent(completeEvent); 161 | 162 | expect(postMessageSpy).toHaveBeenCalledWith( 163 | jasmine.objectContaining({ 164 | kind: 'C', 165 | }), 166 | ); 167 | 168 | // do note here that instead of manually closing the subscription 169 | // we check it's already closed as expected 170 | expect(sub.closed).toBeTrue(); 171 | }); 172 | 173 | it('should not complete the notification stream if the worker does not complete', () => { 174 | const postMessageSpy = spyOn(window, 'postMessage'); 175 | postMessageSpy.calls.reset(); 176 | 177 | class TestWorker implements DoWork { 178 | public work(input$: Observable): Observable { 179 | return new BehaviorSubject(1); 180 | } 181 | } 182 | 183 | const sub = runWorker(TestWorker); 184 | 185 | const event: WorkerMessageNotification = new MessageEvent('message', { 186 | data: new Notification('N', 0), 187 | }); 188 | 189 | self.dispatchEvent(event); 190 | 191 | expect(postMessageSpy).toHaveBeenCalledWith( 192 | jasmine.objectContaining({ 193 | kind: 'N', 194 | value: 1, 195 | }), 196 | ); 197 | 198 | expect(postMessageSpy).not.toHaveBeenCalledWith( 199 | jasmine.objectContaining({ 200 | kind: 'C', 201 | }), 202 | ); 203 | 204 | sub.unsubscribe(); 205 | }); 206 | 207 | it('should permit promises to be returned from doWork to allow for simpler async/await patterns', fakeAsync(() => { 208 | const postMessageSpy = spyOn(window, 'postMessage'); 209 | 210 | class TestWorkerUnit implements DoWorkUnit { 211 | public async workUnit(input: number): Promise { 212 | return input * 2; 213 | } 214 | } 215 | 216 | const sub = runWorker(TestWorkerUnit); 217 | 218 | const event: WorkerMessageNotification = new MessageEvent('message', { 219 | data: new Notification('N', 1), 220 | }); 221 | 222 | self.dispatchEvent(event); 223 | 224 | tick(); 225 | 226 | expect(postMessageSpy).toHaveBeenCalledWith( 227 | jasmine.objectContaining({ 228 | kind: 'N', 229 | value: 2, 230 | }), 231 | ); 232 | 233 | sub.unsubscribe(); 234 | })); 235 | }); 236 | -------------------------------------------------------------------------------- /projects/observable-webworker/src/lib/run-worker.ts: -------------------------------------------------------------------------------- 1 | import { from, fromEvent, Notification, Observable, Subscription } from 'rxjs'; 2 | import { concatMap, dematerialize, map, materialize } from 'rxjs/operators'; 3 | import { DoTransferableWork, DoWork, DoWorkUnit, WorkerMessageNotification } from './observable-worker.types'; 4 | 5 | export type ObservableWorkerConstructor = new (...args: any[]) => DoWork | DoWorkUnit; 6 | 7 | /** @internal */ 8 | export type WorkerPostMessageNotification = (message: Notification, tranferables?: Transferable[]) => void; 9 | 10 | /** @internal */ 11 | export function workerIsTransferableType( 12 | worker: DoWork | DoWorkUnit, 13 | ): worker is DoTransferableWork { 14 | return !!worker.selectTransferables; 15 | } 16 | 17 | /** @internal */ 18 | export function workerIsUnitType(worker: DoWork | DoWorkUnit): worker is DoWorkUnit { 19 | return !!(worker as DoWorkUnit).workUnit; 20 | } 21 | 22 | /** @internal */ 23 | export function getWorkerResult( 24 | worker: DoWork | DoWorkUnit, 25 | incomingMessages$: Observable>, 26 | ): Observable> { 27 | const input$ = incomingMessages$.pipe( 28 | map( 29 | (e: WorkerMessageNotification): Notification => new Notification(e.data.kind, e.data.value, e.data.error), 30 | ), 31 | dematerialize(), 32 | ); 33 | 34 | return workerIsUnitType(worker) 35 | ? // note we intentionally materialize the inner observable so the main thread can reassemble the multiple stream values per input observable 36 | input$.pipe(concatMap(input => from(worker.workUnit(input)).pipe(materialize()))) 37 | : worker.work(input$).pipe(materialize()); 38 | } 39 | 40 | export function runWorker(workerConstructor: ObservableWorkerConstructor): Subscription { 41 | const worker = new workerConstructor(); 42 | 43 | const incomingMessages$ = fromEvent>(self, 'message'); 44 | 45 | return getWorkerResult(worker, incomingMessages$).subscribe((notification: Notification) => { 46 | // type to workaround typescript trying to compile as non-webworker context 47 | const workerPostMessage = (postMessage as unknown) as WorkerPostMessageNotification; 48 | 49 | if (workerIsTransferableType(worker) && notification.hasValue) { 50 | workerPostMessage(notification, worker.selectTransferables(notification.value as O)); 51 | } else { 52 | workerPostMessage(notification); 53 | } 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /projects/observable-webworker/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of observable-webworker 3 | */ 4 | 5 | export * from './lib/observable-worker.types'; 6 | export * from './lib/observable-worker.decorator'; 7 | export * from './lib/run-worker'; 8 | export * from './lib/from-worker'; 9 | export * from './lib/from-worker-pool'; 10 | -------------------------------------------------------------------------------- /projects/observable-webworker/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js'; 4 | import 'zone.js/testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 7 | 8 | // First, initialize the Angular testing environment. 9 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 10 | teardown: { destroyAfterEach: false }, 11 | }); 12 | -------------------------------------------------------------------------------- /projects/observable-webworker/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "declarationMap": true, 6 | "declaration": true, 7 | "inlineSources": true, 8 | "types": [], 9 | "lib": ["dom", "es2018"], 10 | "strict": true, 11 | "removeComments": true 12 | }, 13 | "angularCompilerOptions": { 14 | "skipTemplateCodegen": true, 15 | "strictMetadataEmit": true, 16 | "fullTemplateTypeCheck": true, 17 | "strictInjectionParameters": true, 18 | "enableResourceInlining": true 19 | }, 20 | "exclude": ["src/test.ts", "**/*.spec.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /projects/observable-webworker/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /projects/observable-webworker/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": ["jasmine", "node"], 6 | "strict": true 7 | }, 8 | "files": ["src/test.ts"], 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | Fork me on GitHub 8 | 9 | 10 |

Welcome to observable-webworker!

11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | #fork-link { 2 | position: absolute; 3 | top: 0; 4 | right: 0; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async, waitForAsync } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | 4 | describe('AppComponent', () => { 5 | beforeEach( 6 | waitForAsync(() => { 7 | TestBed.configureTestingModule({ 8 | declarations: [AppComponent], 9 | }).compileComponents(); 10 | }), 11 | ); 12 | 13 | it('should create the app', () => { 14 | const fixture = TestBed.createComponent(AppComponent); 15 | const app = fixture.debugElement.componentInstance; 16 | expect(app).toBeTruthy(); 17 | }); 18 | 19 | it(`should have as title 'observable-webworker'`, () => { 20 | const fixture = TestBed.createComponent(AppComponent); 21 | fixture.detectChanges(); 22 | const app = fixture.debugElement.componentInstance; 23 | expect(app.title).toEqual('observable-webworker'); 24 | }); 25 | 26 | it('should render title in a h1 tag', () => { 27 | const fixture = TestBed.createComponent(AppComponent); 28 | fixture.detectChanges(); 29 | const compiled = fixture.debugElement.nativeElement; 30 | expect(compiled.querySelector('h1').textContent).toContain('Welcome to observable-webworker!'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | styleUrls: ['./app.component.scss'], 7 | }) 8 | export class AppComponent {} 9 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { AppComponent } from './app.component'; 5 | import { SingleWorkerComponent } from './single-worker/single-worker.component'; 6 | import { MultipleWorkerPoolComponent } from './multiple-worker-pool/multiple-worker-pool.component'; 7 | import { LogLineComponent } from './multiple-worker-pool/log-line/log-line.component'; 8 | 9 | @NgModule({ 10 | declarations: [AppComponent, SingleWorkerComponent, MultipleWorkerPoolComponent, LogLineComponent], 11 | imports: [BrowserModule], 12 | providers: [], 13 | bootstrap: [AppComponent], 14 | }) 15 | export class AppModule {} 16 | -------------------------------------------------------------------------------- /src/app/doc/async-work.worker.ts: -------------------------------------------------------------------------------- 1 | import { DoWorkUnit, runWorker } from 'observable-webworker'; 2 | 3 | function factorize(input: number): number[] { 4 | return []; // actual implementation left for the reader :) 5 | } 6 | 7 | export class FactorizationWorker implements DoWorkUnit { 8 | public async workUnit(input: number): Promise { 9 | return factorize(input); 10 | } 11 | } 12 | 13 | runWorker(FactorizationWorker); 14 | -------------------------------------------------------------------------------- /src/app/file-hash.worker.ts: -------------------------------------------------------------------------------- 1 | import * as md5 from 'js-md5'; 2 | import { runWorker } from 'observable-webworker'; 3 | import { Observable, ReplaySubject, Subject } from 'rxjs'; 4 | import { map, take, tap } from 'rxjs/operators'; 5 | import { DoWorkUnit } from '../../projects/observable-webworker/src/lib/observable-worker.types'; 6 | import { FileHashEvent, HashWorkerMessage, Thread } from './hash-worker.types'; 7 | 8 | export class FileHashWorker implements DoWorkUnit { 9 | public workUnit(input: File): Observable { 10 | const output$: Subject = new ReplaySubject(Infinity); 11 | 12 | const log = (fileEventType: FileHashEvent, message: string): HashWorkerMessage => ({ 13 | file: input.name, 14 | timestamp: new Date(), 15 | message, 16 | thread: Thread.WORKER, 17 | fileEventType, 18 | }); 19 | 20 | output$.next(log(FileHashEvent.FILE_RECEIVED, `received file`)); 21 | this.readFileAsArrayBuffer(input) 22 | .pipe( 23 | tap(() => output$.next(log(FileHashEvent.FILE_READ, `read file`))), 24 | map(arrayBuffer => md5(arrayBuffer)), 25 | map((digest: string): HashWorkerMessage => log(FileHashEvent.HASH_COMPUTED, `hash result: ${digest}`)), 26 | tap(out => { 27 | output$.next(out); 28 | output$.complete(); 29 | }), 30 | take(1), 31 | ) 32 | .subscribe(); 33 | 34 | return output$; 35 | } 36 | 37 | private readFileAsArrayBuffer(blob: Blob): Observable { 38 | return new Observable(observer => { 39 | if (!(blob instanceof Blob)) { 40 | observer.error(new Error('`blob` must be an instance of File or Blob.')); 41 | return; 42 | } 43 | 44 | const reader = new FileReader(); 45 | 46 | reader.onerror = err => observer.error(err); 47 | reader.onload = () => observer.next(reader.result as ArrayBuffer); 48 | reader.onloadend = () => observer.complete(); 49 | 50 | reader.readAsArrayBuffer(blob); 51 | 52 | return () => reader.abort(); 53 | }); 54 | } 55 | } 56 | 57 | runWorker(FileHashWorker); 58 | -------------------------------------------------------------------------------- /src/app/google-charts.service.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { Injectable, Type } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | import { GoogleCharts } from 'google-charts'; 5 | 6 | export interface GoogleVis { 7 | Timeline: Type; 8 | DataTable: Type; 9 | events: any; 10 | } 11 | 12 | @Injectable({ 13 | providedIn: 'root', 14 | }) 15 | export class GoogleChartsService { 16 | constructor() {} 17 | 18 | public getVisualisation(...withPackages: string[]): Observable { 19 | return new Observable(observer => { 20 | // Load the charts library with a callback 21 | GoogleCharts.load(() => { 22 | GoogleCharts.api.charts.load('current', { packages: withPackages }); 23 | GoogleCharts.api.charts.setOnLoadCallback(() => { 24 | observer.next(GoogleCharts.api.visualization); 25 | observer.complete(); 26 | }); 27 | }); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/hash-worker.types.ts: -------------------------------------------------------------------------------- 1 | export interface HashWorkerMessage { 2 | file?: string; 3 | timestamp: Date; 4 | message: string; 5 | thread: Thread; 6 | fileEventType: FileHashEvent | null; // null if not a file event 7 | // values computed after emission 8 | millisSinceLast?: number; 9 | } 10 | 11 | export enum Thread { 12 | WORKER = 'worker', 13 | MAIN = 'main', 14 | } 15 | 16 | export enum FileHashEvent { 17 | SELECTED = 'selected', 18 | PICKED_UP = 'picked_up', 19 | FILE_RECEIVED = 'file_received', 20 | FILE_READ = 'file_read', 21 | HASH_COMPUTED = 'hash_computed', 22 | HASH_RECEIVED = 'hash_received', 23 | } 24 | -------------------------------------------------------------------------------- /src/app/multiple-worker-pool/log-line/log-line.component.html: -------------------------------------------------------------------------------- 1 | 2 | {{ message.timestamp | date: 'HH:mm:ss.SSS' }} 3 | 4 | +{{ message.millisSinceLast / 1000 | number: '1.3-3' }}s 5 | 6 | 7 | {{ message.file }} 8 | [{{ message.thread }}] 9 | {{ message.message }} 10 | 11 | -------------------------------------------------------------------------------- /src/app/multiple-worker-pool/log-line/log-line.component.scss: -------------------------------------------------------------------------------- 1 | span { 2 | padding-left: 5px; 3 | display: inline-block; 4 | } 5 | 6 | .message { 7 | font-size: 20px; 8 | } 9 | 10 | .timestamp { 11 | color: gray; 12 | } 13 | 14 | .timedelta { 15 | width: 70px; 16 | text-align: right; 17 | margin-right: 10px; 18 | } 19 | 20 | .thread { 21 | font-weight: bold; 22 | width: 70px; 23 | &.main { 24 | color: darkblue; 25 | } 26 | &.worker { 27 | color: darkgreen; 28 | } 29 | } 30 | 31 | .file { 32 | overflow: hidden; 33 | text-overflow: ellipsis; 34 | width: 300px; 35 | white-space: nowrap; 36 | } 37 | -------------------------------------------------------------------------------- /src/app/multiple-worker-pool/log-line/log-line.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { LogLineComponent } from './log-line.component'; 4 | 5 | describe('LogLineComponent', () => { 6 | let component: LogLineComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach( 10 | waitForAsync(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [LogLineComponent], 13 | }).compileComponents(); 14 | }), 15 | ); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(LogLineComponent); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | }); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/multiple-worker-pool/log-line/log-line.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; 2 | import { HashWorkerMessage } from '../../hash-worker.types'; 3 | 4 | @Component({ 5 | selector: 'app-log-line', 6 | templateUrl: './log-line.component.html', 7 | styleUrls: ['./log-line.component.scss'], 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | }) 10 | export class LogLineComponent implements OnInit { 11 | @Input() message: HashWorkerMessage; 12 | @Input() files: string[] = []; 13 | 14 | public color: string; 15 | 16 | constructor() {} 17 | 18 | ngOnInit() { 19 | if (this.message?.file) { 20 | this.color = `hsl(${(this.files.indexOf(this.message.file) / this.files.length) * 360}, 70%, 60%)`; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/multiple-worker-pool/multiple-worker-pool.component.html: -------------------------------------------------------------------------------- 1 |

Multiple Worker Pool

2 |

({{ status }})

3 | 4 |

Select multiple files of varying sizes to compute MD5 sum of, in pool of webworkers:

5 | 6 | 7 |
8 | (No files are uploaded; they're kept entirely within your browser) 9 |

10 | ℹ️ large files (>10MB) gives the best results otherwise the timing starts to be dominated by the UI updates rather 11 | than the computation of hashes 12 |

13 |
14 | 15 |

Timeline

16 |
17 | 18 |

Events:

19 |
    20 |
  1. 21 | 22 |
  2. 23 |
24 | -------------------------------------------------------------------------------- /src/app/multiple-worker-pool/multiple-worker-pool.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/observable-webworker/01e401789c7849067c94723ded348cbd3aadd1c4/src/app/multiple-worker-pool/multiple-worker-pool.component.scss -------------------------------------------------------------------------------- /src/app/multiple-worker-pool/multiple-worker-pool.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { MultipleWorkerPoolComponent } from './multiple-worker-pool.component'; 4 | 5 | describe('MultipleWorkerPoolComponent', () => { 6 | let component: MultipleWorkerPoolComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach( 10 | waitForAsync(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [MultipleWorkerPoolComponent], 13 | }).compileComponents(); 14 | }), 15 | ); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(MultipleWorkerPoolComponent); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | }); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/multiple-worker-pool/multiple-worker-pool.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ElementRef, ViewChild } from '@angular/core'; 2 | import { 3 | animationFrameScheduler, 4 | asyncScheduler, 5 | combineLatest, 6 | concat, 7 | interval, 8 | Observable, 9 | of, 10 | ReplaySubject, 11 | Subject, 12 | } from 'rxjs'; 13 | import { 14 | delay, 15 | filter, 16 | groupBy, 17 | map, 18 | mergeMap, 19 | observeOn, 20 | scan, 21 | shareReplay, 22 | startWith, 23 | switchMap, 24 | switchMapTo, 25 | take, 26 | takeUntil, 27 | tap, 28 | } from 'rxjs/operators'; 29 | import { fromWorkerPool } from '../../../projects/observable-webworker/src/lib/from-worker-pool'; 30 | import { GoogleChartsService } from '../google-charts.service'; 31 | import { FileHashEvent, HashWorkerMessage, Thread } from '../hash-worker.types'; 32 | import TimelineOptions = google.visualization.TimelineOptions; 33 | 34 | @Component({ 35 | selector: 'app-multiple-worker-pool', 36 | templateUrl: './multiple-worker-pool.component.html', 37 | styleUrls: ['./multiple-worker-pool.component.scss'], 38 | changeDetection: ChangeDetectionStrategy.OnPush, 39 | }) 40 | export class MultipleWorkerPoolComponent { 41 | @ViewChild('timeline', { read: ElementRef }) private timelineComponent!: ElementRef; 42 | 43 | public multiFilesToHash: Subject = new ReplaySubject(1); 44 | public workResult$ = this.multiFilesToHash.pipe( 45 | observeOn(asyncScheduler), 46 | switchMap(files => this.hashMultipleFiles(files)), 47 | ); 48 | 49 | public filenames$ = this.multiFilesToHash.pipe( 50 | map(files => files.map(f => f.name)), 51 | shareReplay(1), 52 | ); 53 | 54 | public eventsPool$: Subject = new Subject(); 55 | 56 | public completedFiles$: Observable = this.filenames$.pipe( 57 | switchMap(() => 58 | this.eventsPool$.pipe( 59 | groupBy(m => m.file), 60 | mergeMap(fileMessage$ => 61 | fileMessage$.pipe( 62 | filter(e => e.fileEventType === FileHashEvent.HASH_RECEIVED), 63 | take(1), 64 | ), 65 | ), 66 | map(message => message.file), 67 | filter((filename): filename is string => !!filename), 68 | scan((files, file) => [...files, file], []), 69 | startWith([]), 70 | ), 71 | ), 72 | ); 73 | 74 | public complete$: Observable = combineLatest([this.filenames$, this.completedFiles$]).pipe( 75 | map(([files, completedFiles]) => files.length === completedFiles.length), 76 | ); 77 | 78 | public status$: Observable = concat(of(null), this.complete$).pipe( 79 | map(isComplete => { 80 | switch (isComplete) { 81 | case null: 82 | return 'Waiting for file selection'; 83 | case true: 84 | return 'Completed'; 85 | case false: 86 | return 'Processing files'; 87 | } 88 | }), 89 | ); 90 | 91 | public eventListPool$: Observable = this.eventsPool$.pipe( 92 | scan((list, event) => { 93 | list.push(event); 94 | return list; 95 | }, []), 96 | map(events => { 97 | const lastEventMap = new Map(); 98 | 99 | return events 100 | .sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf()) 101 | .map(event => { 102 | const lastEvent = lastEventMap.get(event.file); 103 | 104 | lastEventMap.set(event.file, event); 105 | 106 | return { 107 | ...event, 108 | millisSinceLast: lastEvent ? event.timestamp.valueOf() - lastEvent.timestamp.valueOf() : null, 109 | }; 110 | }); 111 | }), 112 | ); 113 | 114 | public chartObserver$ = combineLatest([this.filenames$, this.googleChartService.getVisualisation('timeline')]).pipe( 115 | switchMap(([filenames, visualization]) => { 116 | const container = this.timelineComponent.nativeElement; 117 | const chart = new visualization.Timeline(container); 118 | const dataTable = new visualization.DataTable(); 119 | 120 | dataTable.addColumn({ type: 'string', id: 'file' }); 121 | dataTable.addColumn({ type: 'string', id: 'event' }); 122 | dataTable.addColumn({ type: 'date', id: 'Start' }); 123 | dataTable.addColumn({ type: 'date', id: 'End' }); 124 | 125 | const lastRow = new Map(); 126 | 127 | const chartOptions: TimelineOptions & { hAxis: any } = { 128 | height: 0, 129 | hAxis: { 130 | minValue: new Date(), 131 | maxValue: new Date(new Date().valueOf() + 1000 * 20), 132 | }, 133 | }; 134 | 135 | const eventUpdates$ = this.eventsPool$.pipe( 136 | tap(event => { 137 | if (event.fileEventType === null) { 138 | return; 139 | } 140 | 141 | const timestamp = event.timestamp; 142 | 143 | if (lastRow.has(event.file)) { 144 | dataTable.setCell(lastRow.get(event.file), 3, timestamp); 145 | } 146 | 147 | let durationName: string; 148 | switch (event.fileEventType) { 149 | case FileHashEvent.SELECTED: 150 | durationName = 'Queued, waiting for worker'; 151 | break; 152 | case FileHashEvent.PICKED_UP: 153 | durationName = 'Transferring file to worker'; 154 | if (event.file && filenames.indexOf(event.file) < navigator.hardwareConcurrency - 1) { 155 | durationName = 'Starting worker, ' + durationName; 156 | } 157 | break; 158 | case FileHashEvent.FILE_RECEIVED: 159 | durationName = 'Reading file'; 160 | break; 161 | case FileHashEvent.FILE_READ: 162 | durationName = 'Computing hash'; 163 | break; 164 | case FileHashEvent.HASH_COMPUTED: 165 | durationName = 'Returning hash result to main thread'; 166 | break; 167 | case FileHashEvent.HASH_RECEIVED: 168 | durationName = 'Main thread received hash'; 169 | break; 170 | } 171 | 172 | const row = dataTable.addRow([event.file, durationName, timestamp, timestamp]); 173 | 174 | if (event.fileEventType === FileHashEvent.HASH_RECEIVED) { 175 | lastRow.delete(event.file); 176 | } else { 177 | lastRow.set(event.file, row); 178 | } 179 | 180 | chartOptions.height = filenames.length * 41 + 50; 181 | 182 | chart.draw(dataTable, chartOptions); 183 | }), 184 | ); 185 | 186 | const realtimeUpdater$ = interval(0, animationFrameScheduler).pipe( 187 | tap(() => { 188 | const rowsToUpdate = Array.from(lastRow.values()); 189 | 190 | for (const row of rowsToUpdate) { 191 | dataTable.setCell(row, 3, new Date()); 192 | } 193 | 194 | if (rowsToUpdate.length) { 195 | const currentDateTime = new Date().valueOf(); 196 | if (currentDateTime > chartOptions.hAxis.maxValue.valueOf() - 1000 * 2) { 197 | chartOptions.hAxis.maxValue = new Date(currentDateTime + 1000 * 20); 198 | } 199 | 200 | chart.draw(dataTable, chartOptions); 201 | } 202 | }), 203 | ); 204 | 205 | return eventUpdates$.pipe( 206 | switchMapTo(realtimeUpdater$), 207 | takeUntil( 208 | this.complete$.pipe( 209 | filter(c => c), 210 | take(1), 211 | delay(0), 212 | ), 213 | ), 214 | ); 215 | }), 216 | ); 217 | 218 | constructor(private googleChartService: GoogleChartsService) {} 219 | 220 | private *workPool(files: File[]): IterableIterator { 221 | for (const file of files) { 222 | yield file; 223 | this.eventsPool$.next(this.logMessage(FileHashEvent.PICKED_UP, `file picked up for processing`, file.name)); 224 | } 225 | } 226 | 227 | public hashMultipleFiles(files: File[]): Observable { 228 | const queue: IterableIterator = this.workPool(files); 229 | 230 | return fromWorkerPool(index => { 231 | const worker = new Worker(new URL('../file-hash.worker', import.meta.url), { 232 | name: `hash-worker-${index}`, 233 | type: 'module', 234 | }); 235 | this.eventsPool$.next(this.logMessage(null, `worker ${index} created`)); 236 | return worker; 237 | }, queue).pipe( 238 | tap(res => { 239 | this.eventsPool$.next(res); 240 | if (res.fileEventType === FileHashEvent.HASH_COMPUTED) { 241 | this.eventsPool$.next({ 242 | ...res, 243 | fileEventType: FileHashEvent.HASH_RECEIVED, 244 | timestamp: new Date(), 245 | message: 'hash received', 246 | thread: Thread.MAIN, 247 | }); 248 | } 249 | }), 250 | ); 251 | } 252 | 253 | public calculateMD5Multiple($event: Event): void { 254 | const files: File[] = Array.from(($event.target as HTMLInputElement).files || []); 255 | this.multiFilesToHash.next(files); 256 | for (const file of files) { 257 | this.eventsPool$.next(this.logMessage(FileHashEvent.SELECTED, 'file selected', file.name)); 258 | } 259 | } 260 | 261 | private logMessage(eventType: FileHashEvent | null, message: string, file?: string): HashWorkerMessage { 262 | return { message, file, timestamp: new Date(), thread: Thread.MAIN, fileEventType: eventType }; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/app/single-worker/single-worker.component.html: -------------------------------------------------------------------------------- 1 |

Single Worker

2 | 3 | Select file to compute MD5 sum of, in webworker: 4 | 5 | 6 |

7 | ℹ️ If you select a particularly large file (>1GB) you will have time to select another file and see that the original 8 | worker is terminated and a new worker is created for the new file. This is because the main thread uses a 9 | switchMap 10 | , but if this behaviour was not desired, simply swapping in a 11 | mergeMap 12 | would allow both files to complete hashing. 13 |

14 | 15 |

Events:

16 |
    17 |
  1. {{ event }}
  2. 18 |
19 | -------------------------------------------------------------------------------- /src/app/single-worker/single-worker.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/observable-webworker/01e401789c7849067c94723ded348cbd3aadd1c4/src/app/single-worker/single-worker.component.scss -------------------------------------------------------------------------------- /src/app/single-worker/single-worker.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { SingleWorkerComponent } from './single-worker.component'; 4 | 5 | describe('SingleWorkerComponent', () => { 6 | let component: SingleWorkerComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach( 10 | waitForAsync(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [SingleWorkerComponent], 13 | }).compileComponents(); 14 | }), 15 | ); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(SingleWorkerComponent); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | }); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/single-worker/single-worker.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { Observable, of, Subject } from 'rxjs'; 3 | import { scan, switchMap, tap } from 'rxjs/operators'; 4 | import { fromWorker } from '../../../projects/observable-webworker/src/lib/from-worker'; 5 | import { HashWorkerMessage } from '../hash-worker.types'; 6 | 7 | @Component({ 8 | selector: 'app-single-worker', 9 | templateUrl: './single-worker.component.html', 10 | styleUrls: ['./single-worker.component.scss'], 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class SingleWorkerComponent { 14 | public events$: Subject = new Subject(); 15 | public eventList$: Observable = this.events$.pipe( 16 | scan((list, event) => { 17 | list.push(event); 18 | return list; 19 | }, []), 20 | ); 21 | 22 | private filesToHash: Subject = new Subject(); 23 | 24 | public hashResult$ = this.filesToHash.pipe(switchMap(file => this.hashFile(file))); 25 | 26 | public calculateMD5($event: Event): void { 27 | this.events$.next('Main: file selected'); 28 | const file = ($event.target as HTMLInputElement)?.files?.[0]; 29 | 30 | if (file) { 31 | this.filesToHash.next(file); 32 | } 33 | } 34 | 35 | public hashFile(file: Blob): Observable { 36 | const input$: Observable = of(file); 37 | 38 | return fromWorker(() => { 39 | const worker = new Worker(new URL('../file-hash.worker', import.meta.url), { 40 | name: 'md5-worker', 41 | type: 'module', 42 | }); 43 | this.events$.next('Main: worker created'); 44 | return worker; 45 | }, input$).pipe( 46 | tap(res => { 47 | this.events$.next(`Worker: ${res.message}`); 48 | }), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/observable-webworker/01e401789c7849067c94723ded348cbd3aadd1c4/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/observable-webworker/01e401789c7849067c94723ded348cbd3aadd1c4/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ObservableWorker 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch(err => console.error(err)); 14 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * By default, zone.js will patch all possible macroTask and DomEvents 23 | * user can disable parts of macroTask/DomEvents patch by setting following flags 24 | * because those flags need to be set before `zone.js` being loaded, and webpack 25 | * will put import in the top of bundle, so user need to create a separate file 26 | * in this directory (for example: zone-flags.ts), and put the following flags 27 | * into that file, and then add the following code before importing zone.js. 28 | * import './zone-flags.ts'; 29 | * 30 | * The flags allowed in zone-flags.ts are listed here. 31 | * 32 | * The following flags will work for all browsers. 33 | * 34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 37 | * 38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 40 | * 41 | * (window as any).__Zone_enable_cross_context_check = true; 42 | * 43 | */ 44 | 45 | /*************************************************************************************************** 46 | * Zone JS is required by default for Angular itself. 47 | */ 48 | import 'zone.js'; // Included with Angular CLI. 49 | 50 | /*************************************************************************************************** 51 | * APPLICATION IMPORTS 52 | */ 53 | -------------------------------------------------------------------------------- /src/readme/hello-legacy-webpack.ts: -------------------------------------------------------------------------------- 1 | import { fromWorker } from 'observable-webworker'; 2 | import { of } from 'rxjs'; 3 | 4 | const input$ = of('Hello from main thread'); 5 | 6 | fromWorker(() => new Worker('./hello.worker', { type: 'module' }), input$).subscribe(message => { 7 | console.log(message); // Outputs 'Hello from webworker' 8 | }); 9 | -------------------------------------------------------------------------------- /src/readme/hello.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck - @todo remove typechecking prevention once the typescript config supports it 2 | import { fromWorker } from 'observable-webworker'; 3 | import { of } from 'rxjs'; 4 | 5 | const input$ = of('Hello from main thread'); 6 | 7 | fromWorker( 8 | () => new Worker(new URL('./hello.worker', import.meta.url), { type: 'module' }), 9 | input$, 10 | ).subscribe(message => { 11 | console.log(message); // Outputs 'Hello from webworker' 12 | }); 13 | -------------------------------------------------------------------------------- /src/readme/hello.worker.ts: -------------------------------------------------------------------------------- 1 | import { DoWork, runWorker } from 'observable-webworker'; 2 | import { Observable } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | 5 | export class HelloWorker implements DoWork { 6 | public work(input$: Observable): Observable { 7 | return input$.pipe( 8 | map(message => { 9 | console.log(message); // outputs 'Hello from main thread' 10 | return `Hello from webworker`; 11 | }), 12 | ); 13 | } 14 | } 15 | 16 | runWorker(HelloWorker); 17 | -------------------------------------------------------------------------------- /src/readme/transferable.main.ts: -------------------------------------------------------------------------------- 1 | import { fromWorker } from 'observable-webworker'; 2 | import { Observable, of } from 'rxjs'; 3 | 4 | export function computeHash(arrayBuffer: ArrayBuffer): Observable { 5 | const input$ = of(arrayBuffer); 6 | 7 | return fromWorker( 8 | () => new Worker(new URL('./transferable.worker', import.meta.url), { type: 'module' }), 9 | input$, 10 | input => [input], 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/readme/worker-pool-hash.worker.ts: -------------------------------------------------------------------------------- 1 | import * as md5 from 'js-md5'; 2 | import { DoWorkUnit, runWorker } from 'observable-webworker'; 3 | import { Observable } from 'rxjs'; 4 | import { map } from 'rxjs/operators'; 5 | 6 | export class WorkerPoolHashWorker implements DoWorkUnit { 7 | public workUnit(input: File): Observable { 8 | return this.readFileAsArrayBuffer(input).pipe(map(arrayBuffer => md5(arrayBuffer))); 9 | } 10 | 11 | private readFileAsArrayBuffer(blob: Blob): Observable { 12 | return new Observable(observer => { 13 | if (!(blob instanceof Blob)) { 14 | observer.error(new Error('`blob` must be an instance of File or Blob.')); 15 | return; 16 | } 17 | 18 | const reader = new FileReader(); 19 | 20 | reader.onerror = err => observer.error(err); 21 | reader.onload = () => observer.next(reader.result as ArrayBuffer); 22 | reader.onloadend = () => observer.complete(); 23 | 24 | reader.readAsArrayBuffer(blob); 25 | 26 | return () => reader.abort(); 27 | }); 28 | } 29 | } 30 | 31 | runWorker(WorkerPoolHashWorker); 32 | -------------------------------------------------------------------------------- /src/readme/worker-pool.main.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { fromWorkerPool } from 'observable-webworker'; 3 | 4 | export function computeHashes(files: File[]): Observable { 5 | return fromWorkerPool( 6 | () => new Worker(new URL('./worker-pool-hash.worker', import.meta.url), { type: 'module' }), 7 | files, 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | * { 3 | font-family: sans-serif; 4 | } 5 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 6 | 7 | // First, initialize the Angular testing environment. 8 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 9 | teardown: { destroyAfterEach: false }, 10 | }); 11 | -------------------------------------------------------------------------------- /test-files/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudnc/observable-webworker/01e401789c7849067c94723ded348cbd3aadd1c4/test-files/.gitkeep -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts", "src/polyfills.ts"], 8 | "include": ["src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "stripInternal": true, 7 | "sourceMap": true, 8 | "declaration": false, 9 | "module": "es2020", 10 | "moduleResolution": "node", 11 | "experimentalDecorators": true, 12 | "importHelpers": true, 13 | "target": "ES2022", 14 | "typeRoots": ["node_modules/@types"], 15 | "lib": ["es2018", "dom"], 16 | "paths": { 17 | "observable-webworker": ["projects/observable-webworker/src/public-api"] 18 | }, 19 | "useDefineForClassFields": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "files": ["src/test.ts", "src/polyfills.ts"], 8 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.worker.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/worker", 5 | "lib": ["es2018", "webworker"], 6 | "types": [] 7 | }, 8 | "include": ["src/**/*.worker.ts"] 9 | } 10 | --------------------------------------------------------------------------------