├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── esm.js ├── extras.js ├── index.js ├── package-lock.json ├── package.json ├── scripts ├── babel-plugins.js ├── build.js └── mocha-require.js ├── src ├── Observable.js └── extras.js └── test ├── all.js ├── concat.js ├── constructor.js ├── extras ├── combine-latest.js ├── merge.js ├── parse.js └── zip.js ├── filter.js ├── flat-map.js ├── for-each.js ├── from.js ├── map.js ├── observer-closed.js ├── observer-complete.js ├── observer-error.js ├── observer-next.js ├── of.js ├── properties.js ├── reduce.js ├── setup.js ├── species.js ├── subscribe.js └── subscription.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = false 6 | indent_style = space 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": ["eslint:recommended"], 3 | 4 | "env": { 5 | "es2018": true, 6 | "node": true 7 | }, 8 | 9 | "globals": { 10 | "setTimeout": true 11 | }, 12 | 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | 17 | "rules": { 18 | "no-console": ["error", { "allow": ["warn", "error"] }], 19 | "no-unsafe-finally": ["off"], 20 | "camelcase": ["error", { "properties": "always" }], 21 | "brace-style": ["off"], 22 | "eqeqeq": ["error", "smart"], 23 | "indent": ["error", 2, { "SwitchCase": 1 }], 24 | "no-throw-literal": ["error"], 25 | "comma-spacing": ["error", { "before": false, "after": true }], 26 | "comma-style": ["error", "last"], 27 | "comma-dangle": ["error", "always-multiline"], 28 | "keyword-spacing": ["error"], 29 | "no-trailing-spaces": ["error"], 30 | "no-multi-spaces": ["error"], 31 | "no-spaced-func": ["error"], 32 | "no-whitespace-before-property": ["error"], 33 | "space-before-blocks": ["error"], 34 | "space-before-function-paren": ["error", "never"], 35 | "space-in-parens": ["error", "never"], 36 | "eol-last": ["error"], 37 | "quotes": ["error", "single", { "avoidEscape": true }], 38 | "no-implicit-globals": ["error"], 39 | "no-useless-concat": ["error"], 40 | "space-infix-ops": ["error", { "int32Hint": true }], 41 | "semi-spacing": ["error", { "before": false, "after": true }], 42 | "semi": ["error", "always", { "omitLastInOneLineBlock": true }], 43 | "object-curly-spacing": ["error", "always"], 44 | "array-bracket-spacing": ["error"], 45 | "max-len": ["error", 100] 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | _* 3 | node_modules 4 | lib 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 zenparsing (Kevin Smith) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 17 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zen-observable 2 | 3 | An implementation of Observables for JavaScript. Requires Promises or a Promise polyfill. 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install zen-observable 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import Observable from 'zen-observable'; 15 | 16 | Observable.of(1, 2, 3).subscribe(x => console.log(x)); 17 | ``` 18 | 19 | ## API 20 | 21 | ### new Observable(subscribe) 22 | 23 | ```js 24 | let observable = new Observable(observer => { 25 | // Emit a single value after 1 second 26 | let timer = setTimeout(() => { 27 | observer.next('hello'); 28 | observer.complete(); 29 | }, 1000); 30 | 31 | // On unsubscription, cancel the timer 32 | return () => clearTimeout(timer); 33 | }); 34 | ``` 35 | 36 | Creates a new Observable object using the specified subscriber function. The subscriber function is called whenever the `subscribe` method of the observable object is invoked. The subscriber function is passed an *observer* object which has the following methods: 37 | 38 | - `next(value)` Sends the next value in the sequence. 39 | - `error(exception)` Terminates the sequence with an exception. 40 | - `complete()` Terminates the sequence successfully. 41 | - `closed` A boolean property whose value is `true` if the observer's subscription is closed. 42 | 43 | The subscriber function can optionally return either a cleanup function or a subscription object. If it returns a cleanup function, that function will be called when the subscription has closed. If it returns a subscription object, then the subscription's `unsubscribe` method will be invoked when the subscription has closed. 44 | 45 | ### Observable.of(...items) 46 | 47 | ```js 48 | // Logs 1, 2, 3 49 | Observable.of(1, 2, 3).subscribe(x => { 50 | console.log(x); 51 | }); 52 | ``` 53 | 54 | Returns an observable which will emit each supplied argument. 55 | 56 | ### Observable.from(value) 57 | 58 | ```js 59 | let list = [1, 2, 3]; 60 | 61 | // Iterate over an object 62 | Observable.from(list).subscribe(x => { 63 | console.log(x); 64 | }); 65 | ``` 66 | 67 | ```js 68 | // Convert something 'observable' to an Observable instance 69 | Observable.from(otherObservable).subscribe(x => { 70 | console.log(x); 71 | }); 72 | ``` 73 | 74 | Converts `value` to an Observable. 75 | 76 | - If `value` is an implementation of Observable, then it is converted to an instance of Observable as defined by this library. 77 | - Otherwise, it is converted to an Observable which synchronously iterates over `value`. 78 | 79 | ### observable.subscribe([observer]) 80 | 81 | ```js 82 | let subscription = observable.subscribe({ 83 | next(x) { console.log(x) }, 84 | error(err) { console.log(`Finished with error: ${ err }`) }, 85 | complete() { console.log('Finished') } 86 | }); 87 | ``` 88 | 89 | Subscribes to the observable. Observer objects may have any of the following methods: 90 | 91 | - `next(value)` Receives the next value of the sequence. 92 | - `error(exception)` Receives the terminating error of the sequence. 93 | - `complete()` Called when the stream has completed successfully. 94 | 95 | Returns a subscription object that can be used to cancel the stream. 96 | 97 | ### observable.subscribe(nextCallback[, errorCallback, completeCallback]) 98 | 99 | ```js 100 | let subscription = observable.subscribe( 101 | x => console.log(x), 102 | err => console.log(`Finished with error: ${ err }`), 103 | () => console.log('Finished') 104 | ); 105 | ``` 106 | 107 | Subscribes to the observable with callback functions. Returns a subscription object that can be used to cancel the stream. 108 | 109 | ### observable.forEach(callback) 110 | 111 | ```js 112 | observable.forEach(x => { 113 | console.log(`Received value: ${ x }`); 114 | }).then(() => { 115 | console.log('Finished successfully') 116 | }).catch(err => { 117 | console.log(`Finished with error: ${ err }`); 118 | }) 119 | ``` 120 | 121 | Subscribes to the observable and returns a Promise for the completion value of the stream. The `callback` argument is called once for each value in the stream. 122 | 123 | ### observable.filter(callback) 124 | 125 | ```js 126 | Observable.of(1, 2, 3).filter(value => { 127 | return value > 2; 128 | }).subscribe(value => { 129 | console.log(value); 130 | }); 131 | // 3 132 | ``` 133 | 134 | Returns a new Observable that emits all values which pass the test implemented by the `callback` argument. 135 | 136 | ### observable.map(callback) 137 | 138 | Returns a new Observable that emits the results of calling the `callback` argument for every value in the stream. 139 | 140 | ```js 141 | Observable.of(1, 2, 3).map(value => { 142 | return value * 2; 143 | }).subscribe(value => { 144 | console.log(value); 145 | }); 146 | // 2 147 | // 4 148 | // 6 149 | ``` 150 | 151 | ### observable.reduce(callback [,initialValue]) 152 | 153 | ```js 154 | Observable.of(0, 1, 2, 3, 4).reduce((previousValue, currentValue) => { 155 | return previousValue + currentValue; 156 | }).subscribe(result => { 157 | console.log(result); 158 | }); 159 | // 10 160 | ``` 161 | 162 | Returns a new Observable that applies a function against an accumulator and each value of the stream to reduce it to a single value. 163 | 164 | ### observable.concat(...sources) 165 | 166 | ```js 167 | Observable.of(1, 2, 3).concat( 168 | Observable.of(4, 5, 6), 169 | Observable.of(7, 8, 9) 170 | ).subscribe(result => { 171 | console.log(result); 172 | }); 173 | // 1, 2, 3, 4, 5, 6, 7, 8, 9 174 | ``` 175 | 176 | Merges the current observable with additional observables. 177 | 178 | ### observable.all() 179 | 180 | ```js 181 | let observable = Observable.of(1, 2, 3); 182 | for (let value of await observable.all()) { 183 | console.log(value); 184 | } 185 | // 1, 2, 3 186 | ``` 187 | 188 | Returns a `Promise` for an array containing all of the values produced by the observable. 189 | -------------------------------------------------------------------------------- /esm.js: -------------------------------------------------------------------------------- 1 | import { Observable } from './src/Observable.js'; 2 | 3 | export default Observable; 4 | export { Observable }; 5 | export * from './src/extras.js'; 6 | -------------------------------------------------------------------------------- /extras.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/extras.js'); 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/Observable.js').Observable; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zen-observable", 3 | "version": "0.10.0", 4 | "repository": "zenparsing/zen-observable", 5 | "description": "An Implementation of ES Observables", 6 | "homepage": "https://github.com/zenparsing/zen-observable", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "@babel/cli": "^7.6.0", 10 | "@babel/core": "^7.6.0", 11 | "@babel/preset-env": "^7.6.0", 12 | "@babel/register": "^7.6.0", 13 | "eslint": "^8.26.0", 14 | "mocha": "^10.1.0" 15 | }, 16 | "scripts": { 17 | "test": "mocha --recursive --require ./scripts/mocha-require", 18 | "lint": "eslint src/*", 19 | "build": "git clean -dfX ./lib && node ./scripts/build", 20 | "prepublishOnly": "npm run lint && npm test && npm run build" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /scripts/babel-plugins.js: -------------------------------------------------------------------------------- 1 | module.exports = ['@babel/plugin-transform-modules-commonjs']; 2 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process'); 2 | const plugins = require('./babel-plugins'); 3 | 4 | execSync('babel src --out-dir lib --plugins=' + plugins.join(','), { 5 | env: process.env, 6 | stdio: 'inherit', 7 | }); 8 | -------------------------------------------------------------------------------- /scripts/mocha-require.js: -------------------------------------------------------------------------------- 1 | require('@babel/register')({ 2 | plugins: require('./babel-plugins'), 3 | }); 4 | -------------------------------------------------------------------------------- /src/Observable.js: -------------------------------------------------------------------------------- 1 | // === Symbol Support === 2 | 3 | const hasSymbol = name => Boolean(Symbol[name]); 4 | const getSymbol = name => hasSymbol(name) ? Symbol[name] : '@@' + name; 5 | 6 | const SymbolIterator = getSymbol('iterator'); 7 | const SymbolObservable = getSymbol('observable'); 8 | const SymbolSpecies = getSymbol('species'); 9 | 10 | // === Abstract Operations === 11 | 12 | function getMethod(obj, key) { 13 | let value = obj[key]; 14 | 15 | if (value == null) 16 | return undefined; 17 | 18 | if (typeof value !== 'function') 19 | throw new TypeError(value + ' is not a function'); 20 | 21 | return value; 22 | } 23 | 24 | function getSpecies(obj) { 25 | let ctor = obj.constructor; 26 | if (ctor !== undefined) { 27 | ctor = ctor[SymbolSpecies]; 28 | if (ctor === null) { 29 | ctor = undefined; 30 | } 31 | } 32 | return ctor !== undefined ? ctor : Observable; 33 | } 34 | 35 | function isObservable(x) { 36 | return x instanceof Observable; // SPEC: Brand check 37 | } 38 | 39 | function hostReportError(e) { 40 | if (hostReportError.log) { 41 | hostReportError.log(e); 42 | } else { 43 | setTimeout(() => { throw e }); 44 | } 45 | } 46 | 47 | function enqueue(fn) { 48 | Promise.resolve().then(() => { 49 | try { fn() } 50 | catch (e) { hostReportError(e) } 51 | }); 52 | } 53 | 54 | function cleanupSubscription(subscription) { 55 | let cleanup = subscription._cleanup; 56 | if (cleanup === undefined) 57 | return; 58 | 59 | subscription._cleanup = undefined; 60 | 61 | if (!cleanup) { 62 | return; 63 | } 64 | 65 | try { 66 | if (typeof cleanup === 'function') { 67 | cleanup(); 68 | } else { 69 | let unsubscribe = getMethod(cleanup, 'unsubscribe'); 70 | if (unsubscribe) { 71 | unsubscribe.call(cleanup); 72 | } 73 | } 74 | } catch (e) { 75 | hostReportError(e); 76 | } 77 | } 78 | 79 | function closeSubscription(subscription) { 80 | subscription._observer = undefined; 81 | subscription._queue = undefined; 82 | subscription._state = 'closed'; 83 | } 84 | 85 | function flushSubscription(subscription) { 86 | let queue = subscription._queue; 87 | if (!queue) { 88 | return; 89 | } 90 | subscription._queue = undefined; 91 | subscription._state = 'ready'; 92 | for (let i = 0; i < queue.length; ++i) { 93 | notifySubscription(subscription, queue[i].type, queue[i].value); 94 | if (subscription._state === 'closed') 95 | break; 96 | } 97 | } 98 | 99 | function notifySubscription(subscription, type, value) { 100 | subscription._state = 'running'; 101 | 102 | let observer = subscription._observer; 103 | 104 | try { 105 | let m = getMethod(observer, type); 106 | switch (type) { 107 | case 'next': 108 | if (m) m.call(observer, value); 109 | break; 110 | case 'error': 111 | closeSubscription(subscription); 112 | if (m) m.call(observer, value); 113 | else throw value; 114 | break; 115 | case 'complete': 116 | closeSubscription(subscription); 117 | if (m) m.call(observer); 118 | break; 119 | } 120 | } catch (e) { 121 | hostReportError(e); 122 | } 123 | 124 | if (subscription._state === 'closed') 125 | cleanupSubscription(subscription); 126 | else if (subscription._state === 'running') 127 | subscription._state = 'ready'; 128 | } 129 | 130 | function onNotify(subscription, type, value) { 131 | if (subscription._state === 'closed') 132 | return; 133 | 134 | if (subscription._state === 'buffering') { 135 | subscription._queue.push({ type, value }); 136 | return; 137 | } 138 | 139 | if (subscription._state !== 'ready') { 140 | subscription._state = 'buffering'; 141 | subscription._queue = [{ type, value }]; 142 | enqueue(() => flushSubscription(subscription)); 143 | return; 144 | } 145 | 146 | notifySubscription(subscription, type, value); 147 | } 148 | 149 | 150 | class Subscription { 151 | 152 | constructor(observer, subscriber) { 153 | // ASSERT: observer is an object 154 | // ASSERT: subscriber is callable 155 | 156 | this._cleanup = undefined; 157 | this._observer = observer; 158 | this._queue = undefined; 159 | this._state = 'initializing'; 160 | 161 | let self = this; 162 | 163 | let subscriptionObserver = { 164 | get closed() { return self._state === 'closed' }, 165 | next(value) { onNotify(self, 'next', value) }, 166 | error(value) { onNotify(self, 'error', value) }, 167 | complete() { onNotify(self, 'complete') }, 168 | }; 169 | 170 | try { 171 | this._cleanup = subscriber.call(undefined, subscriptionObserver); 172 | } catch (e) { 173 | subscriptionObserver.error(e); 174 | } 175 | 176 | if (this._state === 'initializing') 177 | this._state = 'ready'; 178 | } 179 | 180 | get closed() { 181 | return this._state === 'closed'; 182 | } 183 | 184 | unsubscribe() { 185 | if (this._state !== 'closed') { 186 | closeSubscription(this); 187 | cleanupSubscription(this); 188 | } 189 | } 190 | } 191 | 192 | export class Observable { 193 | 194 | constructor(subscriber) { 195 | if (!(this instanceof Observable)) 196 | throw new TypeError('Observable cannot be called as a function'); 197 | 198 | if (typeof subscriber !== 'function') 199 | throw new TypeError('Observable initializer must be a function'); 200 | 201 | this._subscriber = subscriber; 202 | } 203 | 204 | subscribe(observer) { 205 | if (typeof observer !== 'object' || observer === null) { 206 | observer = { 207 | next: observer, 208 | error: arguments[1], 209 | complete: arguments[2], 210 | }; 211 | } 212 | return new Subscription(observer, this._subscriber); 213 | } 214 | 215 | forEach(fn) { 216 | return new Promise((resolve, reject) => { 217 | if (typeof fn !== 'function') { 218 | reject(new TypeError(fn + ' is not a function')); 219 | return; 220 | } 221 | 222 | function done() { 223 | subscription.unsubscribe(); 224 | resolve(); 225 | } 226 | 227 | let subscription = this.subscribe({ 228 | next(value) { 229 | try { 230 | fn(value, done); 231 | } catch (e) { 232 | reject(e); 233 | subscription.unsubscribe(); 234 | } 235 | }, 236 | error: reject, 237 | complete: resolve, 238 | }); 239 | }); 240 | } 241 | 242 | map(fn) { 243 | if (typeof fn !== 'function') 244 | throw new TypeError(fn + ' is not a function'); 245 | 246 | let C = getSpecies(this); 247 | 248 | return new C(observer => this.subscribe({ 249 | next(value) { 250 | try { value = fn(value) } 251 | catch (e) { return observer.error(e) } 252 | observer.next(value); 253 | }, 254 | error(e) { observer.error(e) }, 255 | complete() { observer.complete() }, 256 | })); 257 | } 258 | 259 | filter(fn) { 260 | if (typeof fn !== 'function') 261 | throw new TypeError(fn + ' is not a function'); 262 | 263 | let C = getSpecies(this); 264 | 265 | return new C(observer => this.subscribe({ 266 | next(value) { 267 | try { if (!fn(value)) return; } 268 | catch (e) { return observer.error(e) } 269 | observer.next(value); 270 | }, 271 | error(e) { observer.error(e) }, 272 | complete() { observer.complete() }, 273 | })); 274 | } 275 | 276 | reduce(fn) { 277 | if (typeof fn !== 'function') 278 | throw new TypeError(fn + ' is not a function'); 279 | 280 | let C = getSpecies(this); 281 | let hasSeed = arguments.length > 1; 282 | let hasValue = false; 283 | let seed = arguments[1]; 284 | let acc = seed; 285 | 286 | return new C(observer => this.subscribe({ 287 | 288 | next(value) { 289 | let first = !hasValue; 290 | hasValue = true; 291 | 292 | if (!first || hasSeed) { 293 | try { acc = fn(acc, value) } 294 | catch (e) { return observer.error(e) } 295 | } else { 296 | acc = value; 297 | } 298 | }, 299 | 300 | error(e) { observer.error(e) }, 301 | 302 | complete() { 303 | if (!hasValue && !hasSeed) 304 | return observer.error(new TypeError('Cannot reduce an empty sequence')); 305 | 306 | observer.next(acc); 307 | observer.complete(); 308 | }, 309 | 310 | })); 311 | } 312 | 313 | async all() { 314 | let values = []; 315 | await this.forEach(value => values.push(value)); 316 | return values; 317 | } 318 | 319 | concat(...sources) { 320 | let C = getSpecies(this); 321 | 322 | return new C(observer => { 323 | let subscription; 324 | let index = 0; 325 | 326 | function startNext(next) { 327 | subscription = next.subscribe({ 328 | next(v) { observer.next(v) }, 329 | error(e) { observer.error(e) }, 330 | complete() { 331 | if (index === sources.length) { 332 | subscription = undefined; 333 | observer.complete(); 334 | } else { 335 | startNext(C.from(sources[index++])); 336 | } 337 | }, 338 | }); 339 | } 340 | 341 | startNext(this); 342 | 343 | return () => { 344 | if (subscription) { 345 | subscription.unsubscribe(); 346 | subscription = undefined; 347 | } 348 | }; 349 | }); 350 | } 351 | 352 | flatMap(fn) { 353 | if (typeof fn !== 'function') 354 | throw new TypeError(fn + ' is not a function'); 355 | 356 | let C = getSpecies(this); 357 | 358 | return new C(observer => { 359 | let subscriptions = []; 360 | 361 | let outer = this.subscribe({ 362 | next(value) { 363 | if (fn) { 364 | try { value = fn(value) } 365 | catch (e) { return observer.error(e) } 366 | } 367 | 368 | let inner = C.from(value).subscribe({ 369 | next(value) { observer.next(value) }, 370 | error(e) { observer.error(e) }, 371 | complete() { 372 | let i = subscriptions.indexOf(inner); 373 | if (i >= 0) subscriptions.splice(i, 1); 374 | completeIfDone(); 375 | }, 376 | }); 377 | 378 | subscriptions.push(inner); 379 | }, 380 | error(e) { observer.error(e) }, 381 | complete() { completeIfDone() }, 382 | }); 383 | 384 | function completeIfDone() { 385 | if (outer.closed && subscriptions.length === 0) 386 | observer.complete(); 387 | } 388 | 389 | return () => { 390 | subscriptions.forEach(s => s.unsubscribe()); 391 | outer.unsubscribe(); 392 | }; 393 | }); 394 | } 395 | 396 | [SymbolObservable]() { return this } 397 | 398 | static from(x) { 399 | let C = typeof this === 'function' ? this : Observable; 400 | 401 | if (x == null) 402 | throw new TypeError(x + ' is not an object'); 403 | 404 | let method = getMethod(x, SymbolObservable); 405 | if (method) { 406 | let observable = method.call(x); 407 | 408 | if (Object(observable) !== observable) 409 | throw new TypeError(observable + ' is not an object'); 410 | 411 | if (isObservable(observable) && observable.constructor === C) 412 | return observable; 413 | 414 | return new C(observer => observable.subscribe(observer)); 415 | } 416 | 417 | if (hasSymbol('iterator')) { 418 | method = getMethod(x, SymbolIterator); 419 | if (method) { 420 | return new C(observer => { 421 | enqueue(() => { 422 | if (observer.closed) return; 423 | for (let item of method.call(x)) { 424 | observer.next(item); 425 | if (observer.closed) return; 426 | } 427 | observer.complete(); 428 | }); 429 | }); 430 | } 431 | } 432 | 433 | if (Array.isArray(x)) { 434 | return new C(observer => { 435 | enqueue(() => { 436 | if (observer.closed) return; 437 | for (let i = 0; i < x.length; ++i) { 438 | observer.next(x[i]); 439 | if (observer.closed) return; 440 | } 441 | observer.complete(); 442 | }); 443 | }); 444 | } 445 | 446 | throw new TypeError(x + ' is not observable'); 447 | } 448 | 449 | static of(...items) { 450 | let C = typeof this === 'function' ? this : Observable; 451 | 452 | return new C(observer => { 453 | enqueue(() => { 454 | if (observer.closed) return; 455 | for (let i = 0; i < items.length; ++i) { 456 | observer.next(items[i]); 457 | if (observer.closed) return; 458 | } 459 | observer.complete(); 460 | }); 461 | }); 462 | } 463 | 464 | static get [SymbolSpecies]() { return this } 465 | 466 | } 467 | 468 | Object.defineProperty(Observable, Symbol('extensions'), { 469 | value: { 470 | symbol: SymbolObservable, 471 | hostReportError, 472 | }, 473 | configurable: true, 474 | }); 475 | -------------------------------------------------------------------------------- /src/extras.js: -------------------------------------------------------------------------------- 1 | import { Observable } from './Observable.js'; 2 | 3 | // Emits all values from all inputs in parallel 4 | export function merge(...sources) { 5 | return new Observable(observer => { 6 | if (sources.length === 0) 7 | return Observable.from([]); 8 | 9 | let count = sources.length; 10 | 11 | let subscriptions = sources.map(source => Observable.from(source).subscribe({ 12 | next(v) { 13 | observer.next(v); 14 | }, 15 | error(e) { 16 | observer.error(e); 17 | }, 18 | complete() { 19 | if (--count === 0) 20 | observer.complete(); 21 | }, 22 | })); 23 | 24 | return () => subscriptions.forEach(s => s.unsubscribe()); 25 | }); 26 | } 27 | 28 | // Emits arrays containing the most current values from each input 29 | export function combineLatest(...sources) { 30 | return new Observable(observer => { 31 | if (sources.length === 0) 32 | return Observable.from([]); 33 | 34 | let count = sources.length; 35 | let seen = new Set(); 36 | let seenAll = false; 37 | let values = sources.map(() => undefined); 38 | 39 | let subscriptions = sources.map((source, index) => Observable.from(source).subscribe({ 40 | next(v) { 41 | values[index] = v; 42 | 43 | if (!seenAll) { 44 | seen.add(index); 45 | if (seen.size !== sources.length) 46 | return; 47 | 48 | seen = null; 49 | seenAll = true; 50 | } 51 | 52 | observer.next(Array.from(values)); 53 | }, 54 | error(e) { 55 | observer.error(e); 56 | }, 57 | complete() { 58 | if (--count === 0) 59 | observer.complete(); 60 | }, 61 | })); 62 | 63 | return () => subscriptions.forEach(s => s.unsubscribe()); 64 | }); 65 | } 66 | 67 | // Emits arrays containing the matching index values from each input 68 | export function zip(...sources) { 69 | return new Observable(observer => { 70 | if (sources.length === 0) 71 | return Observable.from([]); 72 | 73 | let queues = sources.map(() => []); 74 | 75 | function done() { 76 | return queues.some((q, i) => q.length === 0 && subscriptions[i].closed); 77 | } 78 | 79 | let subscriptions = sources.map((source, index) => Observable.from(source).subscribe({ 80 | next(v) { 81 | queues[index].push(v); 82 | if (queues.every(q => q.length > 0)) { 83 | observer.next(queues.map(q => q.shift())); 84 | if (done()) 85 | observer.complete(); 86 | } 87 | }, 88 | error(e) { 89 | observer.error(e); 90 | }, 91 | complete() { 92 | if (done()) 93 | observer.complete(); 94 | }, 95 | })); 96 | 97 | return () => subscriptions.forEach(s => s.unsubscribe()); 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /test/all.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | describe('all', ()=> { 4 | it('should receive all the observed values in order as an array', async () => { 5 | let observable = Observable.of(1,2,3,4); 6 | let values = await observable.all(); 7 | 8 | assert.deepEqual(values, [1,2,3,4]); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /test/concat.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | describe('concat', () => { 4 | it('concatenates the supplied Observable arguments', async () => { 5 | let list = []; 6 | 7 | await Observable 8 | .from([1, 2, 3, 4]) 9 | .concat(Observable.of(5, 6, 7)) 10 | .forEach(x => list.push(x)); 11 | 12 | assert.deepEqual(list, [1, 2, 3, 4, 5, 6, 7]); 13 | }); 14 | 15 | it('can be used multiple times to produce the same results', async () => { 16 | const list1 = []; 17 | const list2 = []; 18 | 19 | const concatenated = Observable.from([1, 2, 3, 4]) 20 | .concat(Observable.of(5, 6, 7)); 21 | 22 | await concatenated 23 | .forEach(x => list1.push(x)); 24 | await concatenated 25 | .forEach(x => list2.push(x)); 26 | 27 | assert.deepEqual(list1, [1, 2, 3, 4, 5, 6, 7]); 28 | assert.deepEqual(list2, [1, 2, 3, 4, 5, 6, 7]); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /test/constructor.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { testMethodProperty } from './properties.js'; 3 | 4 | describe('constructor', () => { 5 | it('throws if called as a function', () => { 6 | assert.throws(() => Observable(() => {})); 7 | assert.throws(() => Observable.call({}, () => {})); 8 | }); 9 | 10 | it('throws if the argument is not callable', () => { 11 | assert.throws(() => new Observable({})); 12 | assert.throws(() => new Observable()); 13 | assert.throws(() => new Observable(1)); 14 | assert.throws(() => new Observable('string')); 15 | }); 16 | 17 | it('accepts a function argument', () => { 18 | let result = new Observable(() => {}); 19 | assert.ok(result instanceof Observable); 20 | }); 21 | 22 | it('is the value of Observable.prototype.constructor', () => { 23 | testMethodProperty(Observable.prototype, 'constructor', { 24 | configurable: true, 25 | writable: true, 26 | length: 1, 27 | }); 28 | }); 29 | 30 | it('does not call the subscriber function', () => { 31 | let called = 0; 32 | new Observable(() => { called++ }); 33 | assert.equal(called, 0); 34 | }); 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /test/extras/combine-latest.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { parse } from './parse.js'; 3 | import { combineLatest } from '../../src/extras.js'; 4 | 5 | describe('extras/combineLatest', () => { 6 | it('should emit arrays containing the most recent values', async () => { 7 | let output = []; 8 | await combineLatest( 9 | parse('a-b-c-d'), 10 | parse('-A-B-C-D') 11 | ).forEach( 12 | value => output.push(value.join('')) 13 | ); 14 | assert.deepEqual(output, [ 15 | 'aA', 16 | 'bA', 17 | 'bB', 18 | 'cB', 19 | 'cC', 20 | 'dC', 21 | 'dD', 22 | ]); 23 | }); 24 | 25 | it('should emit values in the correct order', async () => { 26 | let output = []; 27 | await combineLatest( 28 | parse('-a-b-c-d'), 29 | parse('A-B-C-D') 30 | ).forEach( 31 | value => output.push(value.join('')) 32 | ); 33 | assert.deepEqual(output, [ 34 | 'aA', 35 | 'aB', 36 | 'bB', 37 | 'bC', 38 | 'cC', 39 | 'cD', 40 | 'dD', 41 | ]); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/extras/merge.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { parse } from './parse.js'; 3 | import { merge } from '../../src/extras.js'; 4 | 5 | describe('extras/merge', () => { 6 | it('should emit all data from each input in parallel', async () => { 7 | let output = ''; 8 | await merge( 9 | parse('a-b-c-d'), 10 | parse('-A-B-C-D') 11 | ).forEach( 12 | value => output += value 13 | ); 14 | assert.equal(output, 'aAbBcCdD'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/extras/parse.js: -------------------------------------------------------------------------------- 1 | export function parse(string) { 2 | return new Observable(async observer => { 3 | await null; 4 | for (let char of string) { 5 | if (observer.closed) return; 6 | else if (char !== '-') observer.next(char); 7 | await null; 8 | } 9 | observer.complete(); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /test/extras/zip.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { parse } from './parse.js'; 3 | import { zip } from '../../src/extras.js'; 4 | 5 | describe('extras/zip', () => { 6 | it('should emit pairs of corresponding index values', async () => { 7 | let output = []; 8 | await zip( 9 | parse('a-b-c-d'), 10 | parse('-A-B-C-D') 11 | ).forEach( 12 | value => output.push(value.join('')) 13 | ); 14 | assert.deepEqual(output, [ 15 | 'aA', 16 | 'bB', 17 | 'cC', 18 | 'dD', 19 | ]); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/filter.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | describe('filter', () => { 4 | it('filters the results using the supplied callback', async () => { 5 | let list = []; 6 | 7 | await Observable 8 | .from([1, 2, 3, 4]) 9 | .filter(x => x > 2) 10 | .forEach(x => list.push(x)); 11 | 12 | assert.deepEqual(list, [3, 4]); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/flat-map.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | describe('flatMap', () => { 4 | it('maps and flattens the results using the supplied callback', async () => { 5 | let list = []; 6 | 7 | await Observable.of('a', 'b', 'c').flatMap(x => 8 | Observable.of(1, 2, 3).map(y => [x, y]) 9 | ).forEach(x => list.push(x)); 10 | 11 | assert.deepEqual(list, [ 12 | ['a', 1], 13 | ['a', 2], 14 | ['a', 3], 15 | ['b', 1], 16 | ['b', 2], 17 | ['b', 3], 18 | ['c', 1], 19 | ['c', 2], 20 | ['c', 3], 21 | ]); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/for-each.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | describe('forEach', () => { 4 | 5 | it('rejects if the argument is not a function', async () => { 6 | let promise = Observable.of(1, 2, 3).forEach(); 7 | try { 8 | await promise; 9 | assert.ok(false); 10 | } catch (err) { 11 | assert.equal(err.name, 'TypeError'); 12 | } 13 | }); 14 | 15 | it('rejects if the callback throws', async () => { 16 | let error = {}; 17 | try { 18 | await Observable.of(1, 2, 3).forEach(x => { throw error }); 19 | assert.ok(false); 20 | } catch (err) { 21 | assert.equal(err, error); 22 | } 23 | }); 24 | 25 | it('does not execute callback after callback throws', async () => { 26 | let calls = []; 27 | try { 28 | await Observable.of(1, 2, 3).forEach(x => { 29 | calls.push(x); 30 | throw {}; 31 | }); 32 | assert.ok(false); 33 | } catch (err) { 34 | assert.deepEqual(calls, [1]); 35 | } 36 | }); 37 | 38 | it('rejects if the producer calls error', async () => { 39 | let error = {}; 40 | try { 41 | let observer; 42 | let promise = new Observable(x => { observer = x }).forEach(() => {}); 43 | observer.error(error); 44 | await promise; 45 | assert.ok(false); 46 | } catch (err) { 47 | assert.equal(err, error); 48 | } 49 | }); 50 | 51 | it('resolves with undefined if the producer calls complete', async () => { 52 | let observer; 53 | let promise = new Observable(x => { observer = x }).forEach(() => {}); 54 | observer.complete(); 55 | assert.equal(await promise, undefined); 56 | }); 57 | 58 | it('provides a cancellation function as the second argument', async () => { 59 | let observer; 60 | let results = []; 61 | await Observable.of(1, 2, 3).forEach((value, cancel) => { 62 | results.push(value); 63 | if (value > 1) { 64 | return cancel(); 65 | } 66 | }); 67 | assert.deepEqual(results, [1, 2]); 68 | }); 69 | 70 | }); 71 | -------------------------------------------------------------------------------- /test/from.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { testMethodProperty } from './properties.js'; 3 | 4 | describe('from', () => { 5 | const iterable = { 6 | *[Symbol.iterator]() { 7 | yield 1; 8 | yield 2; 9 | yield 3; 10 | }, 11 | }; 12 | 13 | it('is a method on Observable', () => { 14 | testMethodProperty(Observable, 'from', { 15 | configurable: true, 16 | writable: true, 17 | length: 1, 18 | }); 19 | }); 20 | 21 | it('throws if the argument is null', () => { 22 | assert.throws(() => Observable.from(null)); 23 | }); 24 | 25 | it('throws if the argument is undefined', () => { 26 | assert.throws(() => Observable.from(undefined)); 27 | }); 28 | 29 | it('throws if the argument is not observable or iterable', () => { 30 | assert.throws(() => Observable.from({})); 31 | }); 32 | 33 | describe('observables', () => { 34 | it('returns the input if the constructor matches "this"', () => { 35 | let ctor = function() {}; 36 | let observable = new Observable(() => {}); 37 | observable.constructor = ctor; 38 | assert.equal(Observable.from.call(ctor, observable), observable); 39 | }); 40 | 41 | it('wraps the input if it is not an instance of Observable', () => { 42 | let obj = { 43 | 'constructor': Observable, 44 | [observableSymbol]() { return this }, 45 | }; 46 | assert.ok(Observable.from(obj) !== obj); 47 | }); 48 | 49 | it('uses @@observable as the property name unless polyfilled', () => { 50 | let obj = { 51 | 'constructor': Observable, 52 | '@@observable'() { return this }, 53 | }; 54 | assert.ok(Observable.from(obj) !== obj); 55 | }); 56 | 57 | it('throws if @@observable property is not a method', () => { 58 | assert.throws(() => Observable.from({ 59 | [observableSymbol]: 1 60 | })); 61 | }); 62 | 63 | it('returns an observable wrapping @@observable result', () => { 64 | let inner = { 65 | subscribe(x) { 66 | observer = x; 67 | return () => { cleanupCalled = true }; 68 | }, 69 | }; 70 | let observer; 71 | let cleanupCalled = true; 72 | let observable = Observable.from({ 73 | [observableSymbol]() { return inner }, 74 | }); 75 | observable.subscribe(); 76 | assert.equal(typeof observer.next, 'function'); 77 | observer.complete(); 78 | assert.equal(cleanupCalled, true); 79 | }); 80 | }); 81 | 82 | describe('iterables', () => { 83 | it('throws if @@iterator is not a method', () => { 84 | assert.throws(() => Observable.from({ [Symbol.iterator]: 1 })); 85 | }); 86 | 87 | it('returns an observable wrapping iterables', async () => { 88 | let calls = []; 89 | let subscription = Observable.from(iterable).subscribe({ 90 | next(v) { calls.push(['next', v]) }, 91 | complete() { calls.push(['complete']) }, 92 | }); 93 | assert.deepEqual(calls, []); 94 | await null; 95 | assert.deepEqual(calls, [ 96 | ['next', 1], 97 | ['next', 2], 98 | ['next', 3], 99 | ['complete'], 100 | ]); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/map.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | describe('map', () => { 4 | it('maps the results using the supplied callback', async () => { 5 | let list = []; 6 | 7 | await Observable.from([1, 2, 3]) 8 | .map(x => x * 2) 9 | .forEach(x => list.push(x)); 10 | 11 | assert.deepEqual(list, [2, 4, 6]); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/observer-closed.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { testMethodProperty } from './properties.js'; 3 | 4 | describe('observer.closed', () => { 5 | it('is a getter on SubscriptionObserver.prototype', () => { 6 | let observer; 7 | new Observable(x => { observer = x }).subscribe(); 8 | testMethodProperty(observer, 'closed', { 9 | get: true, 10 | configurable: true, 11 | writable: true, 12 | enumerable: true, 13 | length: 1 14 | }); 15 | }); 16 | 17 | it('returns false when the subscription is open', () => { 18 | new Observable(observer => { 19 | assert.equal(observer.closed, false); 20 | }).subscribe(); 21 | }); 22 | 23 | it('returns true when the subscription is completed', () => { 24 | let observer; 25 | new Observable(x => { observer = x; }).subscribe(); 26 | observer.complete(); 27 | assert.equal(observer.closed, true); 28 | }); 29 | 30 | it('returns true when the subscription is errored', () => { 31 | let observer; 32 | new Observable(x => { observer = x; }).subscribe(null, () => {}); 33 | observer.error(); 34 | assert.equal(observer.closed, true); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/observer-complete.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { testMethodProperty } from './properties.js'; 3 | 4 | describe('observer.complete', () => { 5 | 6 | function getObserver(inner) { 7 | let observer; 8 | new Observable(x => { observer = x }).subscribe(inner); 9 | return observer; 10 | } 11 | 12 | it('is a method', () => { 13 | let observer = getObserver(); 14 | testMethodProperty(observer, 'complete', { 15 | configurable: true, 16 | writable: true, 17 | enumerable: true, 18 | length: 0, 19 | }); 20 | }); 21 | 22 | it('is bound', () => { 23 | let done = false; 24 | let observer = getObserver({ complete() { done = true } }); 25 | let { complete } = observer; 26 | complete(); 27 | assert.ok(done); 28 | }); 29 | 30 | it('does not forward arguments', () => { 31 | let args; 32 | let observer = getObserver({ complete(...a) { args = a } }); 33 | observer.complete(1); 34 | assert.deepEqual(args, []); 35 | }); 36 | 37 | it('does not return a value', () => { 38 | let observer = getObserver({ complete() { return 1 } }); 39 | assert.equal(observer.complete(), undefined); 40 | }); 41 | 42 | it('does not forward when the subscription is complete', () => { 43 | let count = 0; 44 | let observer = getObserver({ complete() { count++ } }); 45 | observer.complete(); 46 | observer.complete(); 47 | assert.equal(count, 1); 48 | }); 49 | 50 | it('does not forward when the subscription is cancelled', () => { 51 | let count = 0; 52 | let observer; 53 | let subscription = new Observable(x => { observer = x }).subscribe({ 54 | complete() { count++ }, 55 | }); 56 | subscription.unsubscribe(); 57 | observer.complete(); 58 | assert.equal(count, 0); 59 | }); 60 | 61 | it('queues if the subscription is not initialized', async () => { 62 | let completed = false; 63 | new Observable(x => { x.complete() }).subscribe({ 64 | complete() { completed = true }, 65 | }); 66 | assert.equal(completed, false); 67 | await null; 68 | assert.equal(completed, true); 69 | }); 70 | 71 | it('queues if the observer is running', async () => { 72 | let observer; 73 | let completed = false 74 | new Observable(x => { observer = x }).subscribe({ 75 | next() { observer.complete() }, 76 | complete() { completed = true }, 77 | }); 78 | observer.next(); 79 | assert.equal(completed, false); 80 | await null; 81 | assert.equal(completed, true); 82 | }); 83 | 84 | it('closes the subscription before invoking inner observer', () => { 85 | let closed; 86 | let observer = getObserver({ 87 | complete() { closed = observer.closed }, 88 | }); 89 | observer.complete(); 90 | assert.equal(closed, true); 91 | }); 92 | 93 | it('reports error if "complete" is not a method', () => { 94 | let observer = getObserver({ complete: 1 }); 95 | observer.complete(); 96 | assert.ok(hostError instanceof Error); 97 | }); 98 | 99 | it('does not report error if "complete" is undefined', () => { 100 | let observer = getObserver({ complete: undefined }); 101 | observer.complete(); 102 | assert.ok(!hostError); 103 | }); 104 | 105 | it('does not report error if "complete" is null', () => { 106 | let observer = getObserver({ complete: null }); 107 | observer.complete(); 108 | assert.ok(!hostError); 109 | }); 110 | 111 | it('reports error if "complete" throws', () => { 112 | let error = {}; 113 | let observer = getObserver({ complete() { throw error } }); 114 | observer.complete(); 115 | assert.equal(hostError, error); 116 | }); 117 | 118 | it('calls the cleanup method after "complete"', () => { 119 | let calls = []; 120 | let observer; 121 | new Observable(x => { 122 | observer = x; 123 | return () => { calls.push('cleanup') }; 124 | }).subscribe({ 125 | complete() { calls.push('complete') }, 126 | }); 127 | observer.complete(); 128 | assert.deepEqual(calls, ['complete', 'cleanup']); 129 | }); 130 | 131 | it('calls the cleanup method if there is no "complete"', () => { 132 | let calls = []; 133 | let observer; 134 | new Observable(x => { 135 | observer = x; 136 | return () => { calls.push('cleanup') }; 137 | }).subscribe({}); 138 | observer.complete(); 139 | assert.deepEqual(calls, ['cleanup']); 140 | }); 141 | 142 | it('reports error if the cleanup function throws', () => { 143 | let error = {}; 144 | let observer; 145 | new Observable(x => { 146 | observer = x; 147 | return () => { throw error }; 148 | }).subscribe(); 149 | observer.complete(); 150 | assert.equal(hostError, error); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /test/observer-error.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { testMethodProperty } from './properties.js'; 3 | 4 | describe('observer.error', () => { 5 | 6 | function getObserver(inner) { 7 | let observer; 8 | new Observable(x => { observer = x }).subscribe(inner); 9 | return observer; 10 | } 11 | 12 | it('is a method', () => { 13 | let observer = getObserver(); 14 | testMethodProperty(observer, 'error', { 15 | configurable: true, 16 | writable: true, 17 | enumerable: true, 18 | length: 1, 19 | }); 20 | }); 21 | 22 | it('is bound', () => { 23 | let err; 24 | let observer = getObserver({ error(e) { err = e } }); 25 | let { error } = observer; 26 | error('err'); 27 | assert.equal(err, 'err'); 28 | }); 29 | 30 | it('forwards the argument', () => { 31 | let args; 32 | let observer = getObserver({ error(...a) { args = a } }); 33 | observer.error(1); 34 | assert.deepEqual(args, [1]); 35 | }); 36 | 37 | it('does not return a value', () => { 38 | let observer = getObserver({ error() { return 1 } }); 39 | assert.equal(observer.error(), undefined); 40 | }); 41 | 42 | it('does not throw when the subscription is complete', () => { 43 | let observer = getObserver({ error() {} }); 44 | observer.complete(); 45 | observer.error('error'); 46 | }); 47 | 48 | it('does not throw when the subscription is cancelled', () => { 49 | let observer; 50 | let subscription = new Observable(x => { observer = x }).subscribe({ 51 | error() {}, 52 | }); 53 | subscription.unsubscribe(); 54 | observer.error(1); 55 | assert.ok(!hostError); 56 | }); 57 | 58 | it('queues if the subscription is not initialized', async () => { 59 | let error; 60 | new Observable(x => { x.error({}) }).subscribe({ 61 | error(err) { error = err }, 62 | }); 63 | assert.equal(error, undefined); 64 | await null; 65 | assert.ok(error); 66 | }); 67 | 68 | it('queues if the observer is running', async () => { 69 | let observer; 70 | let error; 71 | new Observable(x => { observer = x }).subscribe({ 72 | next() { observer.error({}) }, 73 | error(e) { error = e }, 74 | }); 75 | observer.next(); 76 | assert.ok(!error); 77 | await null; 78 | assert.ok(error); 79 | }); 80 | 81 | it('closes the subscription before invoking inner observer', () => { 82 | let closed; 83 | let observer = getObserver({ 84 | error() { closed = observer.closed }, 85 | }); 86 | observer.error(1); 87 | assert.equal(closed, true); 88 | }); 89 | 90 | it('reports an error if "error" is not a method', () => { 91 | let observer = getObserver({ error: 1 }); 92 | observer.error(1); 93 | assert.ok(hostError); 94 | }); 95 | 96 | it('reports an error if "error" is undefined', () => { 97 | let error = {}; 98 | let observer = getObserver({ error: undefined }); 99 | observer.error(error); 100 | assert.equal(hostError, error); 101 | }); 102 | 103 | it('reports an error if "error" is null', () => { 104 | let error = {}; 105 | let observer = getObserver({ error: null }); 106 | observer.error(error); 107 | assert.equal(hostError, error); 108 | }); 109 | 110 | it('reports error if "error" throws', () => { 111 | let error = {}; 112 | let observer = getObserver({ error() { throw error } }); 113 | observer.error(1); 114 | assert.equal(hostError, error); 115 | }); 116 | 117 | it('calls the cleanup method after "error"', () => { 118 | let calls = []; 119 | let observer; 120 | new Observable(x => { 121 | observer = x; 122 | return () => { calls.push('cleanup') }; 123 | }).subscribe({ 124 | error() { calls.push('error') }, 125 | }); 126 | observer.error(); 127 | assert.deepEqual(calls, ['error', 'cleanup']); 128 | }); 129 | 130 | it('calls the cleanup method if there is no "error"', () => { 131 | let calls = []; 132 | let observer; 133 | new Observable(x => { 134 | observer = x; 135 | return () => { calls.push('cleanup') }; 136 | }).subscribe({}); 137 | try { 138 | observer.error(); 139 | } catch (err) {} 140 | assert.deepEqual(calls, ['cleanup']); 141 | }); 142 | 143 | it('reports error if the cleanup function throws', () => { 144 | let error = {}; 145 | let observer; 146 | new Observable(x => { 147 | observer = x; 148 | return () => { throw error }; 149 | }).subscribe(); 150 | observer.error(1); 151 | assert.equal(hostError, error); 152 | }); 153 | 154 | }); 155 | -------------------------------------------------------------------------------- /test/observer-next.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { testMethodProperty } from './properties.js'; 3 | 4 | describe('observer.next', () => { 5 | 6 | function getObserver(inner) { 7 | let observer; 8 | new Observable(x => { observer = x }).subscribe(inner); 9 | return observer; 10 | } 11 | 12 | it('is a method', () => { 13 | let observer = getObserver(); 14 | testMethodProperty(observer, 'next', { 15 | configurable: true, 16 | writable: true, 17 | enumerable: true, 18 | length: 1, 19 | }); 20 | }); 21 | 22 | it('is bound', () => { 23 | let value; 24 | let observer = getObserver({ next(v) { value = v } }); 25 | let { next } = observer; 26 | next(123); 27 | assert.equal(value, 123); 28 | }); 29 | 30 | it('forwards the first argument', () => { 31 | let args; 32 | let observer = getObserver({ next(...a) { args = a } }); 33 | observer.next(1, 2); 34 | assert.deepEqual(args, [1]); 35 | }); 36 | 37 | it('does not return a value', () => { 38 | let observer = getObserver({ next() { return 1 } }); 39 | assert.equal(observer.next(), undefined); 40 | }); 41 | 42 | it('does not forward when the subscription is complete', () => { 43 | let count = 0; 44 | let observer = getObserver({ next() { count++ } }); 45 | observer.complete(); 46 | observer.next(); 47 | assert.equal(count, 0); 48 | }); 49 | 50 | it('does not forward when the subscription is cancelled', () => { 51 | let count = 0; 52 | let observer; 53 | let subscription = new Observable(x => { observer = x }).subscribe({ 54 | next() { count++ }, 55 | }); 56 | subscription.unsubscribe(); 57 | observer.next(); 58 | assert.equal(count, 0); 59 | }); 60 | 61 | it('remains closed if the subscription is cancelled from "next"', () => { 62 | let observer; 63 | let subscription = new Observable(x => { observer = x }).subscribe({ 64 | next() { subscription.unsubscribe() }, 65 | }); 66 | observer.next(); 67 | assert.equal(observer.closed, true); 68 | }); 69 | 70 | it('queues if the subscription is not initialized', async () => { 71 | let values = []; 72 | let observer; 73 | new Observable(x => { observer = x, x.next(1) }).subscribe({ 74 | next(val) { 75 | values.push(val); 76 | if (val === 1) { 77 | observer.next(3); 78 | } 79 | }, 80 | }); 81 | observer.next(2); 82 | assert.deepEqual(values, []); 83 | await null; 84 | assert.deepEqual(values, [1, 2]); 85 | await null; 86 | assert.deepEqual(values, [1, 2, 3]); 87 | }); 88 | 89 | it('drops queue if subscription is closed', async () => { 90 | let values = []; 91 | let subscription = new Observable(x => { x.next(1) }).subscribe({ 92 | next(val) { values.push(val) }, 93 | }); 94 | assert.deepEqual(values, []); 95 | subscription.unsubscribe(); 96 | await null; 97 | assert.deepEqual(values, []); 98 | }); 99 | 100 | it('queues if the observer is running', async () => { 101 | let observer; 102 | let values = []; 103 | new Observable(x => { observer = x }).subscribe({ 104 | next(val) { 105 | values.push(val); 106 | if (val === 1) observer.next(2); 107 | }, 108 | }); 109 | observer.next(1); 110 | assert.deepEqual(values, [1]); 111 | await null; 112 | assert.deepEqual(values, [1, 2]); 113 | }); 114 | 115 | it('reports error if "next" is not a method', () => { 116 | let observer = getObserver({ next: 1 }); 117 | observer.next(); 118 | assert.ok(hostError); 119 | }); 120 | 121 | it('does not report error if "next" is undefined', () => { 122 | let observer = getObserver({ next: undefined }); 123 | observer.next(); 124 | assert.ok(!hostError); 125 | }); 126 | 127 | it('does not report error if "next" is null', () => { 128 | let observer = getObserver({ next: null }); 129 | observer.next(); 130 | assert.ok(!hostError); 131 | }); 132 | 133 | it('reports error if "next" throws', () => { 134 | let error = {}; 135 | let observer = getObserver({ next() { throw error } }); 136 | observer.next(); 137 | assert.equal(hostError, error); 138 | }); 139 | 140 | it('does not close the subscription on error', () => { 141 | let observer = getObserver({ next() { throw {} } }); 142 | observer.next(); 143 | assert.equal(observer.closed, false); 144 | }); 145 | 146 | }); 147 | -------------------------------------------------------------------------------- /test/of.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { testMethodProperty } from './properties.js'; 3 | 4 | describe('of', () => { 5 | it('is a method on Observable', () => { 6 | testMethodProperty(Observable, 'of', { 7 | configurable: true, 8 | writable: true, 9 | length: 0, 10 | }); 11 | }); 12 | 13 | it('uses the this value if it is a function', () => { 14 | let usesThis = false; 15 | Observable.of.call(function() { usesThis = true; }); 16 | assert.ok(usesThis); 17 | }); 18 | 19 | it('uses Observable if the this value is not a function', () => { 20 | let result = Observable.of.call({}, 1, 2, 3, 4); 21 | assert.ok(result instanceof Observable); 22 | }); 23 | 24 | it('delivers arguments to next in a job', async () => { 25 | let values = []; 26 | let turns = 0; 27 | Observable.of(1, 2, 3, 4).subscribe(v => values.push(v)); 28 | assert.equal(values.length, 0); 29 | await null; 30 | assert.deepEqual(values, [1, 2, 3, 4]); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/properties.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | export function testMethodProperty(object, key, options) { 4 | let desc = Object.getOwnPropertyDescriptor(object, key); 5 | let { enumerable = false, configurable = false, writable = false, length } = options; 6 | 7 | assert.ok(desc, `Property ${ key.toString() } exists`); 8 | 9 | if (options.get || options.set) { 10 | if (options.get) { 11 | assert.equal(typeof desc.get, 'function', 'Getter is a function'); 12 | assert.equal(desc.get.length, 0, 'Getter length is 0'); 13 | } else { 14 | assert.equal(desc.get, undefined, 'Getter is undefined'); 15 | } 16 | 17 | if (options.set) { 18 | assert.equal(typeof desc.set, 'function', 'Setter is a function'); 19 | assert.equal(desc.set.length, 1, 'Setter length is 1'); 20 | } else { 21 | assert.equal(desc.set, undefined, 'Setter is undefined'); 22 | } 23 | } else { 24 | assert.equal(typeof desc.value, 'function', 'Value is a function'); 25 | assert.equal(desc.value.length, length, `Function length is ${ length }`); 26 | assert.equal(desc.writable, writable, `Writable property is correct ${ writable }`); 27 | } 28 | 29 | assert.equal(desc.enumerable, enumerable, `Enumerable property is ${ enumerable }`); 30 | assert.equal(desc.configurable, configurable, `Configurable property is ${ configurable }`); 31 | } 32 | -------------------------------------------------------------------------------- /test/reduce.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | describe('reduce', () => { 4 | it('reduces without a seed', async () => { 5 | await Observable.from([1, 2, 3, 4, 5, 6]).reduce((a, b) => { 6 | return a + b; 7 | }).forEach(x => { 8 | assert.equal(x, 21); 9 | }); 10 | }); 11 | 12 | it('errors if empty and no seed', async () => { 13 | try { 14 | await Observable.from([]).reduce((a, b) => { 15 | return a + b; 16 | }).forEach(() => null); 17 | assert.ok(false); 18 | } catch (err) { 19 | assert.ok(true); 20 | } 21 | }); 22 | 23 | it('reduces with a seed', async () => { 24 | Observable.from([1, 2, 3, 4, 5, 6]).reduce((a, b) => { 25 | return a + b; 26 | }, 100).forEach(x => { 27 | assert.equal(x, 121); 28 | }); 29 | }); 30 | 31 | it('reduces an empty list with a seed', async () => { 32 | await Observable.from([]).reduce((a, b) => { 33 | return a + b; 34 | }, 100).forEach(x => { 35 | assert.equal(x, 100); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import { Observable } from '../src/Observable.js'; 2 | 3 | beforeEach(() => { 4 | global.Observable = Observable; 5 | global.hostError = null; 6 | let $extensions = Object.getOwnPropertySymbols(Observable)[1]; 7 | let { hostReportError, symbol } = Observable[$extensions]; 8 | hostReportError.log = (e => global.hostError = e); 9 | global.observableSymbol = symbol; 10 | }); 11 | -------------------------------------------------------------------------------- /test/species.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | describe('species', () => { 4 | it('uses Observable when constructor is undefined', () => { 5 | let instance = new Observable(() => {}); 6 | instance.constructor = undefined; 7 | assert.ok(instance.map(x => x) instanceof Observable); 8 | }); 9 | 10 | it('uses Observable if species is null', () => { 11 | let instance = new Observable(() => {}); 12 | instance.constructor = { [Symbol.species]: null }; 13 | assert.ok(instance.map(x => x) instanceof Observable); 14 | }); 15 | 16 | it('uses Observable if species is undefined', () => { 17 | let instance = new Observable(() => {}); 18 | instance.constructor = { [Symbol.species]: undefined }; 19 | assert.ok(instance.map(x => x) instanceof Observable); 20 | }); 21 | 22 | it('uses value of Symbol.species', () => { 23 | function ctor() {} 24 | let instance = new Observable(() => {}); 25 | instance.constructor = { [Symbol.species]: ctor }; 26 | assert.ok(instance.map(x => x) instanceof ctor); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/subscribe.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { testMethodProperty } from './properties.js'; 3 | 4 | describe('subscribe', () => { 5 | 6 | it('is a method of Observable.prototype', () => { 7 | testMethodProperty(Observable.prototype, 'subscribe', { 8 | configurable: true, 9 | writable: true, 10 | length: 1, 11 | }); 12 | }); 13 | 14 | it('accepts an observer argument', () => { 15 | let observer; 16 | let nextValue; 17 | new Observable(x => observer = x).subscribe({ 18 | next(v) { nextValue = v }, 19 | }); 20 | observer.next(1); 21 | assert.equal(nextValue, 1); 22 | }); 23 | 24 | it('accepts a next function argument', () => { 25 | let observer; 26 | let nextValue; 27 | new Observable(x => observer = x).subscribe( 28 | v => nextValue = v 29 | ); 30 | observer.next(1); 31 | assert.equal(nextValue, 1); 32 | }); 33 | 34 | it('accepts an error function argument', () => { 35 | let observer; 36 | let errorValue; 37 | let error = {}; 38 | new Observable(x => observer = x).subscribe( 39 | null, 40 | e => errorValue = e 41 | ); 42 | observer.error(error); 43 | assert.equal(errorValue, error); 44 | }); 45 | 46 | it('accepts a complete function argument', () => { 47 | let observer; 48 | let completed = false; 49 | new Observable(x => observer = x).subscribe( 50 | null, 51 | null, 52 | () => completed = true 53 | ); 54 | observer.complete(); 55 | assert.equal(completed, true); 56 | }); 57 | 58 | it('uses function overload if first argument is null', () => { 59 | let observer; 60 | let completed = false; 61 | new Observable(x => observer = x).subscribe( 62 | null, 63 | null, 64 | () => completed = true 65 | ); 66 | observer.complete(); 67 | assert.equal(completed, true); 68 | }); 69 | 70 | it('uses function overload if first argument is undefined', () => { 71 | let observer; 72 | let completed = false; 73 | new Observable(x => observer = x).subscribe( 74 | undefined, 75 | null, 76 | () => completed = true 77 | ); 78 | observer.complete(); 79 | assert.equal(completed, true); 80 | }); 81 | 82 | it('uses function overload if first argument is a primative', () => { 83 | let observer; 84 | let completed = false; 85 | new Observable(x => observer = x).subscribe( 86 | 'abc', 87 | null, 88 | () => completed = true 89 | ); 90 | observer.complete(); 91 | assert.equal(completed, true); 92 | }); 93 | 94 | it('enqueues a job to send error if subscriber throws', async () => { 95 | let error = {}; 96 | let errorValue = undefined; 97 | new Observable(() => { throw error }).subscribe({ 98 | error(e) { errorValue = e }, 99 | }); 100 | assert.equal(errorValue, undefined); 101 | await null; 102 | assert.equal(errorValue, error); 103 | }); 104 | 105 | it('does not send error if unsubscribed', async () => { 106 | let error = {}; 107 | let errorValue = undefined; 108 | let subscription = new Observable(() => { throw error }).subscribe({ 109 | error(e) { errorValue = e }, 110 | }); 111 | subscription.unsubscribe(); 112 | assert.equal(errorValue, undefined); 113 | await null; 114 | assert.equal(errorValue, undefined); 115 | }); 116 | 117 | it('accepts a cleanup function from the subscriber function', () => { 118 | let cleanupCalled = false; 119 | let subscription = new Observable(() => { 120 | return () => cleanupCalled = true; 121 | }).subscribe(); 122 | subscription.unsubscribe(); 123 | assert.equal(cleanupCalled, true); 124 | }); 125 | 126 | it('accepts a subscription object from the subscriber function', () => { 127 | let cleanupCalled = false; 128 | let subscription = new Observable(() => { 129 | return { 130 | unsubscribe() { cleanupCalled = true }, 131 | }; 132 | }).subscribe(); 133 | subscription.unsubscribe(); 134 | assert.equal(cleanupCalled, true); 135 | }); 136 | 137 | }); 138 | -------------------------------------------------------------------------------- /test/subscription.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { testMethodProperty } from './properties.js'; 3 | 4 | describe('subscription', () => { 5 | 6 | function getSubscription(subscriber = () => {}) { 7 | return new Observable(subscriber).subscribe(); 8 | } 9 | 10 | describe('unsubscribe', () => { 11 | it('is a method on Subscription.prototype', () => { 12 | let subscription = getSubscription(); 13 | testMethodProperty(Object.getPrototypeOf(subscription), 'unsubscribe', { 14 | configurable: true, 15 | writable: true, 16 | length: 0, 17 | }); 18 | }); 19 | 20 | it('reports an error if the cleanup function throws', () => { 21 | let error = {}; 22 | let subscription = getSubscription(() => { 23 | return () => { throw error }; 24 | }); 25 | subscription.unsubscribe(); 26 | assert.equal(hostError, error); 27 | }); 28 | }); 29 | 30 | describe('closed', () => { 31 | it('is a getter on Subscription.prototype', () => { 32 | let subscription = getSubscription(); 33 | testMethodProperty(Object.getPrototypeOf(subscription), 'closed', { 34 | configurable: true, 35 | writable: true, 36 | get: true, 37 | }); 38 | }); 39 | }); 40 | 41 | }); 42 | --------------------------------------------------------------------------------