diff --git a/README.md b/README.md index 6c4c258..4c36673 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,236 @@ -A collection of lightweight, RxJS-inspired implementation of the Observer pattern in JavaScript. -Features Observable's with AbortController-based unsubscription, supporting both synchronous and -asynchronous producers. +# [@observable](https://jsr.io/@observable) Monorepo + +A set of lightweight, modern reactive libraries inspired by [RxJS](https://rxjs.dev/), implementing +the [Observer pattern](https://refactoring.guru/design-patterns/observer) in JavaScript. + +## What You'll Find Here + +- A tribute to [RxJS](https://rxjs.dev/), built for real-world use. +- Carefully crafted utilities and primitives by a solo developer who loves reactive programming. + +## What You Won't Find Here + +- A drop-in replacement for [RxJS](https://rxjs.dev/). [RxJS](https://rxjs.dev/) remains the gold + standard for enterprise-scale projects, while this monorepo aims for simplicity and focus. + +## Repository Expectations + +### SOLID Principles + +Adhere to the SOLID design principles as much as possible. We could say a lot, but will defer to +myriad of online resources that outline the merits of these principles. + +- **Single Responsibility** +- **Open/Closed** +- **Liskov Substitution** +- **Interface Segregation** +- **Dependency Inversion** + +### Composition Over Inheritance + +Class definitions should be expressions that leverage +[declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) and +private API obfuscation to achieve pure encapsulation. + +```ts +interface Example { + foo(): void; + bar(): void; +} + +interface ExampleConstructor { + new (): A; + readonly prototype: A; +} + +const Example: ExampleConstructor = class { + foo(): void { + this.#foo(); + } + + #foo(): void { + // Do something + } + + bar(): void { + this.#bar(); + } + + #bar(): void { + // Do something + } +}; +``` + +### Immutability + +All object instances, constructors, and prototypes **must be frozen** to prevent runtime mutation. +This ensures predictable behavior and guards against accidental modification. + +```ts +const Example: ExampleConstructor = class { + constructor() { + Object.freeze(this); + } +}; + +Object.freeze(Example); +Object.freeze(Example.prototype); +``` + +### Runtime Argument Validation + +All public functions and methods **must validate their arguments** at runtime using the standard +`TypeError` type. + +```ts +function example(value: string): void { + if (arguments.length === 0) { + throw new TypeError("1 argument required but 0 present"); + } + if (typeof value !== "string") { + throw new TypeError("Parameter 1 is not of type 'String'"); + } +} +``` + +All public methods **must validate their 'this' instance** at runtime using the standard `TypeError` +type. + +```ts +const Example: ExampleConstructor = class { + foo() { + if (!(this instanceof Example)) { + throw new TypeError("'this' is not instanceof 'Example'"); + } + } +}; +``` + +### Shallow Call Stacks for Debugging + +Validation must occur **at the entry point** of each public function, not delegated to shared +validation helpers buried in the call stack. This keeps stack traces shallow and points errors +directly to the user's call site, making debugging straightforward. + +```ts +// ✓ Good: Validation at entry point produces a shallow stack trace +function map(transform: (value: In) => Out): (source: In[]) => Out[] { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (typeof transform !== "function") { + throw new ParameterTypeError(0, "Function"); + } + return function mapFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!Array.isArray(source)) throw new ParameterTypeError(0, "Array"); + // ... + }; +} + +// ✗ Bad: Delegating to a validation helper adds noise to the stack trace +function map(transform: (value: In) => Out) { + validateFunction(transform); // Adds extra frame(s) to stack + return function mapFn(source) { + validateArray(source); // Adds extra frame(s) to stack + // ... + }; +} +``` + +When a user passes invalid arguments, the error should point directly to their code — not to +internal library plumbing. + +### Type Guards + +Provide `is*` type guard functions for all unique public interfaces. Type guards must: + +- Accept `unknown` and return a type predicate +- Support both class instances and POJOs +- Validate required arguments + +```ts +function isUser(value: unknown): value is User { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + return ( + value instanceof User || + (isObject(value) && + "id" in value && + typeof value.id === "string" && + "name" in value && + typeof value.name === "string") + ); +} +``` + +### Symbol.toStringTag + +All classes **must implement `Symbol.toStringTag`** for proper object stringification. + +```ts +const Example: ExampleConstructor = class { + readonly [Symbol.toStringTag] = "Example"; +}; + +// Usage: +`${new Example()}`; // "[object Example]" +``` + +### Pure Functions + +Functions should be pure wherever possible: + +- **Deterministic** — Same inputs always produce the same output +- **No side effects** — No mutations, I/O, or external state changes +- **Referentially transparent** — Can be replaced with its return value + +### Documentation + +All public APIs **must have JSDoc documentation** including: + +- A description of purpose and behavior +- `@example` blocks with runnable code samples + +````ts +/** + * Calculates the sum of all numbers in an array. + * @example + * ```ts + * sum([1, 2, 3]); // 6 + * sum([]); // 0 + * ``` + */ +export function sum(values: number[]): number; +```` + +### Testing + +Tests follow the **Arrange/Act/Assert** pattern with descriptive test names that explain the +expected behavior. Each test should focus on a single behavior. + +```ts +Deno.test("sum should return 0 for an empty array", () => { + // Arrange + const values: Array = []; + + // Act + const result = sum(values); + + // Assert + assertEquals(result, 0); +}); +``` + +### Internal Utilities + +Reusable utilities should be centralized in a dedicated internal package/module. Internal functions +that should not be exported are marked with `@internal Do NOT export.` in their JSDoc. + +```ts +/** + * Clamps a value between a minimum and maximum. + * @internal Do NOT export. + */ +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} +``` diff --git a/all/README.md b/all/README.md new file mode 100644 index 0000000..e038fe4 --- /dev/null +++ b/all/README.md @@ -0,0 +1,65 @@ +# @observable/all + +Creates and returns an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) whose +[`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values are calculated from the +latest [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values of each of its +[sources](https://jsr.io/@observable/core#source). If any of the +[sources](https://jsr.io/@observable/core#source) are empty, the returned +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) will also be empty. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { all } from "@observable/all"; +import { of } from "@observable/of"; + +const controller = new AbortController(); +all([of([1, 2, 3]), of([4, 5, 6]), of([7, 8, 9])]).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" [3, 6, 7] +// "next" [3, 6, 8] +// "next" [3, 6, 9] +// "return" +``` + +## Example with empty source + +```ts +import { all } from "@observable/all"; +import { of } from "@observable/of"; +import { empty } from "@observable/empty"; + +const controller = new AbortController(); +all([of([1, 2, 3]), empty, of([7, 8, 9])]).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/web/deno.json b/all/deno.json similarity index 67% rename from web/deno.json rename to all/deno.json index ad25663..2dbe8ef 100644 --- a/web/deno.json +++ b/all/deno.json @@ -1,5 +1,5 @@ { - "name": "@xan/observable-web", + "name": "@observable/all", "version": "0.1.0", "license": "MIT", "exports": "./mod.ts" diff --git a/common/all.test.ts b/all/mod.test.ts similarity index 84% rename from common/all.test.ts rename to all/mod.test.ts index 4ca4316..7bc8363 100644 --- a/common/all.test.ts +++ b/all/mod.test.ts @@ -1,15 +1,14 @@ import { assertEquals, assertStrictEquals } from "@std/assert"; -import { Observer } from "@xan/observable-core"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { flat } from "./flat.ts"; -import { defer } from "./defer.ts"; -import { empty } from "./empty.ts"; -import { never } from "./never.ts"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { all } from "./all.ts"; -import { ReplaySubject } from "./replay-subject.ts"; +import { Observer } from "@observable/core"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { flat } from "@observable/flat"; +import { defer } from "@observable/defer"; +import { empty } from "@observable/empty"; +import { never } from "@observable/never"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { all } from "./mod.ts"; +import { ReplaySubject } from "@observable/replay-subject"; Deno.test( "all should multiple sources that next and return synchronously", diff --git a/all/mod.ts b/all/mod.ts new file mode 100644 index 0000000..d95180c --- /dev/null +++ b/all/mod.ts @@ -0,0 +1,103 @@ +import { type Observable, Observer, Subject } from "@observable/core"; +import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@observable/internal"; +import { defer } from "@observable/defer"; +import { empty } from "@observable/empty"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { tap } from "@observable/tap"; +import { map } from "@observable/map"; +import { mergeMap } from "@observable/merge-map"; +import { filter } from "@observable/filter"; +import { takeUntil } from "@observable/take-until"; + +/** + * Creates and returns an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) whose + * [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values are calculated from the latest + * [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values of each of its [sources](https://jsr.io/@observable/core#source). + * If any of the [sources](https://jsr.io/@observable/core#source) are empty, the returned + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) will also be empty. + * @example + * ```ts + * import { all } from "@observable/all"; + * import { of } from "@observable/of"; + * + * const controller = new AbortController(); + * all([of([1, 2, 3]), of([4, 5, 6]), of([7, 8, 9])]).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "next" [3, 6, 7] + * // "next" [3, 6, 8] + * // "next" [3, 6, 9] + * // "return" + * ``` + * @example + * ```ts + * import { all } from "@observable/all"; + * import { of } from "@observable/of"; + * import { empty } from "@observable/empty"; + * + * const controller = new AbortController(); + * all([of([1, 2, 3]), empty, of([7, 8, 9])]).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "return" + * ``` + */ +export function all>( + sources: Readonly<{ [Key in keyof Values]: Observable }>, +): Observable; +export function all( + // Long term, it would be nice to be able to accept an Iterable for performance and flexibility. + // This new signature would have to work in conjunction with the mapped array signature above as this + // encourages more explicit types for sources as a tuple. + sources: ReadonlyArray, +): Observable> { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!Array.isArray(sources)) throw new ParameterTypeError(0, "Array"); + if (sources.length === 0) return empty; + return defer(() => { + let receivedFirstValueCount = 0; + const { length: expectedFirstValueCount } = sources; + const values: Array = []; + const emptySourceNotifier = new Subject(); + return pipe( + of(sources), + mergeMap((source, index) => { + let isEmpty = true; + return pipe( + source, + tap( + new Observer({ + next: processNextValue, + return: processReturn, + throw: noop, + }), + ), + ); + + function processNextValue(value: unknown): void { + if (isEmpty) receivedFirstValueCount++; + isEmpty = false; + values[index] = value; + } + + function processReturn(): void { + if (isEmpty) emptySourceNotifier.next(); + } + }), + filter(() => receivedFirstValueCount === expectedFirstValueCount), + map(() => values.slice()), + takeUntil(emptySourceNotifier), + ); + }); +} diff --git a/as-async-iterable/README.md b/as-async-iterable/README.md new file mode 100644 index 0000000..c40c6db --- /dev/null +++ b/as-async-iterable/README.md @@ -0,0 +1,37 @@ +# @observable/as-async-iterable + +Converts an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) to an `AsyncIterable`. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { asAsyncIterable } from "@observable/as-async-iterable"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +for await (const value of pipe(of([1, 2, 3]), asAsyncIterable())) { + console.log(value); +} + +// Console output: +// 1 +// 2 +// 3 +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/as-async-iterable/deno.json b/as-async-iterable/deno.json new file mode 100644 index 0000000..b41a81b --- /dev/null +++ b/as-async-iterable/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/as-async-iterable", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/as-async-iterable/mod.test.ts b/as-async-iterable/mod.test.ts new file mode 100644 index 0000000..b116e0a --- /dev/null +++ b/as-async-iterable/mod.test.ts @@ -0,0 +1,356 @@ +import { assertEquals, assertRejects, assertStrictEquals, assertThrows } from "@std/assert"; +import { Observable, Observer } from "@observable/core"; +import { pipe } from "@observable/pipe"; +import { asAsyncIterable } from "./mod.ts"; +import { of } from "@observable/of"; +import { throwError } from "@observable/throw-error"; +import { empty } from "@observable/empty"; + +Deno.test("asAsyncIterable should throw when called with no source", () => { + // Arrange + const operator = asAsyncIterable(); + + // Act / Assert + assertThrows( + () => operator(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + +Deno.test("asAsyncIterable should throw when source is not an Observable", () => { + // Arrange + const operator = asAsyncIterable(); + + // Act / Assert + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(1 as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(null as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(undefined as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); +}); + +Deno.test("asAsyncIterable should iterate all values from synchronous observable", async () => { + // Arrange + const source = of([1, 2, 3]); + const iterable = pipe(source, asAsyncIterable()); + const values: number[] = []; + + // Act + for await (const value of iterable) { + values.push(value); + } + + // Assert + assertEquals(values, [1, 2, 3]); +}); + +Deno.test("asAsyncIterable should handle empty observable", async () => { + // Arrange + const iterable = pipe(empty, asAsyncIterable()); + const values: never[] = []; + + // Act + for await (const value of iterable) { + values.push(value); + } + + // Assert + assertEquals(values, []); +}); + +Deno.test("asAsyncIterable should reject on observable throw", async () => { + // Arrange + const error = new Error("test error"); + const iterable = pipe(throwError(error), asAsyncIterable()); + + // Act / Assert + await assertRejects( + async () => { + for await (const _ of iterable) { + // Should not reach here + } + }, + Error, + "test error", + ); +}); + +Deno.test("asAsyncIterable should handle throw after some values", async () => { + // Arrange + const error = new Error("test error"); + const source = new Observable((observer) => { + observer.next(1); + observer.next(2); + observer.throw(error); + }); + const iterable = pipe(source, asAsyncIterable()); + const values: number[] = []; + + // Act / Assert + await assertRejects( + async () => { + for await (const value of iterable) { + values.push(value); + } + }, + Error, + "test error", + ); + assertEquals(values, [1, 2]); +}); + +Deno.test("asAsyncIterable should abort subscription on iterator return", async () => { + // Arrange + let subscriptionAborted = false; + const source = new Observable((observer) => { + observer.signal.addEventListener("abort", () => { + subscriptionAborted = true; + }, { once: true }); + observer.next(1); + observer.next(2); + observer.next(3); + }); + const iterable = pipe(source, asAsyncIterable()); + const values: number[] = []; + + // Act + for await (const value of iterable) { + values.push(value); + if (value === 2) break; + } + + // Assert + assertEquals(values, [1, 2]); + assertStrictEquals(subscriptionAborted, true); +}); + +Deno.test("asAsyncIterable should return done after observable returns", async () => { + // Arrange + const source = of([1, 2]); + const iterable = pipe(source, asAsyncIterable()); + const iterator = iterable[Symbol.asyncIterator](); + + // Act + const result1 = await iterator.next(); + const result2 = await iterator.next(); + const result3 = await iterator.next(); + const result4 = await iterator.next(); + + // Assert + assertEquals(result1, { value: 1, done: false }); + assertEquals(result2, { value: 2, done: false }); + assertEquals(result3, { value: undefined, done: true }); + assertEquals(result4, { value: undefined, done: true }); +}); + +Deno.test("asAsyncIterable should handle async observable emissions", async () => { + // Arrange + const source = new Observable((observer) => { + setTimeout(() => observer.next(1), 10); + setTimeout(() => observer.next(2), 20); + setTimeout(() => observer.next(3), 30); + setTimeout(() => observer.return(), 40); + }); + const iterable = pipe(source, asAsyncIterable()); + const values: number[] = []; + + // Act + for await (const value of iterable) { + values.push(value); + } + + // Assert + assertEquals(values, [1, 2, 3]); +}); + +Deno.test("asAsyncIterable return method should abort subscription and return done", async () => { + // Arrange + let subscriptionAborted = false; + const source = new Observable((observer) => { + observer.signal.addEventListener("abort", () => { + subscriptionAborted = true; + }, { once: true }); + }); + const iterable = pipe(source, asAsyncIterable()); + const iterator = iterable[Symbol.asyncIterator](); + + // Start the subscription by calling next (it will wait since source doesn't emit) + iterator.next(); + + // Act + const returnResult = await iterator.return!(); + + // Assert + assertEquals(returnResult, { value: undefined, done: true }); + assertStrictEquals(subscriptionAborted, true); +}); + +Deno.test("asAsyncIterable throw method should abort subscription and reject", async () => { + // Arrange + let subscriptionAborted = false; + const error = new Error("iterator throw"); + const source = new Observable((observer) => { + observer.signal.addEventListener("abort", () => { + subscriptionAborted = true; + }, { once: true }); + }); + const iterable = pipe(source, asAsyncIterable()); + const iterator = iterable[Symbol.asyncIterator](); + + // Start the subscription + iterator.next(); + + // Act / Assert + await assertRejects( + async () => await iterator.throw!(error), + Error, + "iterator throw", + ); + assertStrictEquals(subscriptionAborted, true); +}); + +Deno.test("asAsyncIterable should buffer values when emitted faster than consumed", async () => { + // Arrange + let capturedObserver: Observer | undefined; + const source = new Observable((observer) => { + capturedObserver = observer; + }); + const iterable = pipe(source, asAsyncIterable()); + const iterator = iterable[Symbol.asyncIterator](); + + // Start iteration to activate subscription + const nextPromise = iterator.next(); + + // Emit multiple values before consuming + capturedObserver!.next(1); + capturedObserver!.next(2); + capturedObserver!.next(3); + + // Act - consume all values + const result1 = await nextPromise; + const result2 = await iterator.next(); + const result3 = await iterator.next(); + + // Assert + assertEquals(result1, { value: 1, done: false }); + assertEquals(result2, { value: 2, done: false }); + assertEquals(result3, { value: 3, done: false }); +}); + +Deno.test("asAsyncIterable should resolve waiting promises when values arrive", async () => { + // Arrange + let capturedObserver: Observer | undefined; + const source = new Observable((observer) => { + capturedObserver = observer; + }); + const iterable = pipe(source, asAsyncIterable()); + const iterator = iterable[Symbol.asyncIterator](); + + // Request values before they're emitted + const promise1 = iterator.next(); + const promise2 = iterator.next(); + + // Act - emit values after requesting + capturedObserver!.next(10); + capturedObserver!.next(20); + + // Assert + const result1 = await promise1; + const result2 = await promise2; + assertEquals(result1, { value: 10, done: false }); + assertEquals(result2, { value: 20, done: false }); +}); + +Deno.test("asAsyncIterable should resolve all pending promises on return", async () => { + // Arrange + let capturedObserver: Observer | undefined; + const source = new Observable((observer) => { + capturedObserver = observer; + }); + const iterable = pipe(source, asAsyncIterable()); + const iterator = iterable[Symbol.asyncIterator](); + + // Request values before return + const promise1 = iterator.next(); + const promise2 = iterator.next(); + + // Act - observable returns + capturedObserver!.return(); + + // Assert + const result1 = await promise1; + const result2 = await promise2; + assertEquals(result1, { value: undefined, done: true }); + assertEquals(result2, { value: undefined, done: true }); +}); + +Deno.test("asAsyncIterable should reject all pending promises on throw", async () => { + // Arrange + const error = new Error("observable error"); + let capturedObserver: Observer | undefined; + const source = new Observable((observer) => { + capturedObserver = observer; + }); + const iterable = pipe(source, asAsyncIterable()); + const iterator = iterable[Symbol.asyncIterator](); + + // Request values before throw + const promise1 = iterator.next(); + const promise2 = iterator.next(); + + // Act - observable throws + capturedObserver!.throw(error); + + // Assert + await assertRejects(async () => await promise1, Error, "observable error"); + await assertRejects(async () => await promise2, Error, "observable error"); +}); + +Deno.test("asAsyncIterable should only start subscription on first next call", async () => { + // Arrange + let subscribed = false; + const source = new Observable((observer) => { + subscribed = true; + observer.next(1); + observer.return(); + }); + const iterable = pipe(source, asAsyncIterable()); + const iterator = iterable[Symbol.asyncIterator](); + + // Assert - not subscribed yet + assertStrictEquals(subscribed, false); + + // Act + await iterator.next(); + + // Assert - now subscribed + assertStrictEquals(subscribed, true); +}); + +Deno.test("asAsyncIterable should reject subsequent next calls after throw", async () => { + // Arrange + const error = new Error("test"); + const source = throwError(error); + const iterable = pipe(source, asAsyncIterable()); + const iterator = iterable[Symbol.asyncIterator](); + + // First next triggers the throw + await assertRejects(async () => await iterator.next(), Error, "test"); + + // Subsequent next calls should also reject + await assertRejects(async () => await iterator.next(), Error, "test"); +}); diff --git a/as-async-iterable/mod.ts b/as-async-iterable/mod.ts new file mode 100644 index 0000000..a24a1e6 --- /dev/null +++ b/as-async-iterable/mod.ts @@ -0,0 +1,104 @@ +import { isObservable, type Observable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; + +/** + * Flag indicating that a value is not thrown. + * @internal Do NOT export. + */ +const notThrown = Symbol("Flag indicating that a value is not thrown."); + +/** + * Converts an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) to a {@linkcode AsyncIterable}. + * @example + * ```ts + * import { asAsyncIterable } from "@observable/as-async-iterable"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * + * for await (const value of pipe(of([1, 2, 3]), asAsyncIterable())) { + * console.log(value); + * } + * + * // Console output: + * // 1 + * // 2 + * // 3 + * ``` + */ +export function asAsyncIterable(): ( + source: Observable, +) => AsyncIterable { + return function asAsyncIterableFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + return { + [Symbol.asyncIterator]() { + let activeSubscriptionController: AbortController | undefined; + let thrownValue: unknown = notThrown; + let returned = false; + const values: Array = []; + const deferredResolvers: Array< + Omit>, "promise"> + > = []; + + return { + next() { + if (!activeSubscriptionController) { + // We only want to start the subscription when the user starts iterating. + activeSubscriptionController = new AbortController(); + source.subscribe({ + signal: activeSubscriptionController.signal, + next(value) { + if (deferredResolvers.length) { + const { resolve } = deferredResolvers.shift()!; + resolve({ value, done: false }); + } else values.push(value); + }, + return() { + returned = true; + while (deferredResolvers.length) { + const { resolve } = deferredResolvers.shift()!; + resolve({ value: undefined, done: true }); + } + }, + throw(value) { + thrownValue = value; + while (deferredResolvers.length) { + const { reject } = deferredResolvers.shift()!; + reject(value); + } + }, + }); + } + + // If we already have some values in our buffer, we'll return the next one. + if (values.length) { + return Promise.resolve({ value: values.shift()!, done: false }); + } + + // This was already returned, so we're just going to return a done result. + if (returned) { + return Promise.resolve({ value: undefined, done: true }); + } + + // There was an error, so we're going to return an error result. + if (thrownValue !== notThrown) return Promise.reject(thrownValue); + + // Otherwise, we need to make them wait for a value. + const { promise, ...resolvers } = Promise.withResolvers>(); + deferredResolvers.push(resolvers); + return promise; + }, + return() { + activeSubscriptionController?.abort(); + return Promise.resolve({ value: undefined, done: true }); + }, + throw(value) { + activeSubscriptionController?.abort(); + return Promise.reject(value); + }, + }; + }, + }; + }; +} diff --git a/as-promise/README.md b/as-promise/README.md new file mode 100644 index 0000000..581676e --- /dev/null +++ b/as-promise/README.md @@ -0,0 +1,33 @@ +# @observable/as-promise + +Converts an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) to a `Promise`. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { asPromise } from "@observable/as-promise"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +console.log(await pipe(of([1, 2, 3]), asPromise())); + +// Console output: +// 3 +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/as-promise/deno.json b/as-promise/deno.json new file mode 100644 index 0000000..9070a62 --- /dev/null +++ b/as-promise/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/as-promise", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/as-promise/mod.test.ts b/as-promise/mod.test.ts new file mode 100644 index 0000000..dfaa0ec --- /dev/null +++ b/as-promise/mod.test.ts @@ -0,0 +1,36 @@ +import { assertEquals, assertStrictEquals } from "@std/assert"; +import type { ObserverNotification } from "@observable/materialize"; +import { pipe } from "@observable/pipe"; +import { asPromise } from "./mod.ts"; +import { of } from "@observable/of"; +import { throwError } from "@observable/throw-error"; + +Deno.test("asPromise should pump throw values right through the Promise", async () => { + // Arrange + const error = new Error("test"); + + // Act + try { + await pipe( + throwError(error), + asPromise(), + ); + } catch (error) { + // Assert + assertStrictEquals(error, error); + } +}); + +Deno.test("asPromise should pump last next value through the Promise", async () => { + // Arrange + const source = of([1, 2, 3]); + + // Act + const value = await pipe( + source, + asPromise(), + ); + + // Assert + assertEquals(value, 3); +}); diff --git a/as-promise/mod.ts b/as-promise/mod.ts new file mode 100644 index 0000000..62e141b --- /dev/null +++ b/as-promise/mod.ts @@ -0,0 +1,31 @@ +import { isObservable, type Observable, Observer } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; +import { AsyncSubject } from "@observable/async-subject"; + +/** + * Converts an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) to a {@linkcode Promise}. + * @example + * ```ts + * import { asPromise } from "@observable/as-promise"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * + * console.log(await pipe(of([1, 2, 3]), asPromise())); + * + * // Console output: + * // 3 + * ``` + */ +export function asPromise(): ( + source: Observable, +) => Promise { + return function asPromiseFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + const { resolve, reject, promise } = Promise.withResolvers(); + const subject = new AsyncSubject(); + subject.subscribe(new Observer({ next: resolve, throw: reject })); + source.subscribe(subject); + return promise; + }; +} diff --git a/async-subject/README.md b/async-subject/README.md new file mode 100644 index 0000000..9043875 --- /dev/null +++ b/async-subject/README.md @@ -0,0 +1,64 @@ +# @observable/async-subject + +A variant of [`Subject`](https://jsr.io/@observable/core/doc/~/Subject) that buffers the most recent +[`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed value until +[`return`](https://jsr.io/@observable/core/doc/~/Observer.return) is called. Once +[`return`](https://jsr.io/@observable/core/doc/~/Observer.return)ed, +[`next`](https://jsr.io/@observable/core/doc/~/Observer.next) will be replayed to late +[`consumers`](https://jsr.io/@observable/core#consumer) upon +[`subscription`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { AsyncSubject } from "@observable/async-subject"; + +const subject = new AsyncSubject(); +const controller = new AbortController(); + +subject.next(1); +subject.next(2); + +subject.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +subject.next(3); + +subject.return(); + +// Console output: +// "next" 3 +// "return" + +subject.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 3 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/async-subject/deno.json b/async-subject/deno.json new file mode 100644 index 0000000..0fe69f4 --- /dev/null +++ b/async-subject/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/async-subject", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/async-subject.test.ts b/async-subject/mod.test.ts similarity index 96% rename from common/async-subject.test.ts rename to async-subject/mod.test.ts index cad51f5..52bbb31 100644 --- a/common/async-subject.test.ts +++ b/async-subject/mod.test.ts @@ -1,9 +1,8 @@ import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; -import { Observable, Observer } from "@xan/observable-core"; -import { AsyncSubject } from "./async-subject.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { pipe } from "./pipe.ts"; +import { Observable, Observer } from "@observable/core"; +import { AsyncSubject } from "./mod.ts"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { pipe } from "@observable/pipe"; Deno.test("AsyncSubject.toString should be '[object AsyncSubject]'", () => { // Arrange / Act / Assert diff --git a/async-subject/mod.ts b/async-subject/mod.ts new file mode 100644 index 0000000..484be23 --- /dev/null +++ b/async-subject/mod.ts @@ -0,0 +1,109 @@ +import { isObserver, type Observer, type Subject } from "@observable/core"; +import { + InstanceofError, + MinimumArgumentsRequiredError, + ParameterTypeError, +} from "@observable/internal"; +import { flat } from "@observable/flat"; +import { pipe } from "@observable/pipe"; +import { ignoreElements } from "@observable/ignore-elements"; +import { ReplaySubject } from "@observable/replay-subject"; + +/** + * Object type that acts as a variant of [`Subject`](https://jsr.io/@observable/core/doc/~/Subject). + */ +export type AsyncSubject = Subject; + +/** + * Object interface for an {@linkcode AsyncSubject} factory. + */ +export interface AsyncSubjectConstructor { + /** + * Creates and returns an object that acts as a [`Subject`](https://jsr.io/@observable/core/doc/~/Subject) that buffers the most recent + * [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed value until [`return`](https://jsr.io/@observable/core/doc/~/Observer.return) is called. + * Once [`return`](https://jsr.io/@observable/core/doc/~/Observer.return)ed, [`next`](https://jsr.io/@observable/core/doc/~/Observer.next) will be replayed + * to late [`consumers`](https://jsr.io/@observable/core#consumer) upon [`subscription`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + * @example + * ```ts + * import { AsyncSubject } from "@observable/async-subject"; + * + * const subject = new AsyncSubject(); + * const controller = new AbortController(); + * + * subject.next(1); + * subject.next(2); + * + * subject.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * subject.next(3); + * + * subject.return(); + * + * // Console output: + * // "next" 3 + * // "return" + * + * subject.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "next" 3 + * // "return" + * ``` + */ + new (): AsyncSubject; + new (): AsyncSubject; + readonly prototype: AsyncSubject; +} + +export const AsyncSubject: AsyncSubjectConstructor = class { + readonly [Symbol.toStringTag] = "AsyncSubject"; + readonly #subject = new ReplaySubject(1); + readonly signal = this.#subject.signal; + readonly #observable = flat([ + pipe(this.#subject, ignoreElements()), + this.#subject, + ]); + + constructor() { + Object.freeze(this); + } + + next(value: unknown): void { + if (this instanceof AsyncSubject) this.#subject.next(value); + else throw new InstanceofError("this", "AsyncSubject"); + } + + return(): void { + if (this instanceof AsyncSubject) this.#subject.return(); + else throw new InstanceofError("this", "AsyncSubject"); + } + + throw(value: unknown): void { + if (this instanceof AsyncSubject) { + this.#subject.next(undefined); + this.#subject.throw(value); + } else throw new InstanceofError("this", "AsyncSubject"); + } + + subscribe(observer: Observer): void { + if (!(this instanceof AsyncSubject)) { + throw new InstanceofError("this", "AsyncSubject"); + } + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObserver(observer)) throw new ParameterTypeError(0, "Observer"); + this.#observable.subscribe(observer); + } +}; + +Object.freeze(AsyncSubject); +Object.freeze(AsyncSubject.prototype); diff --git a/behavior-subject/README.md b/behavior-subject/README.md new file mode 100644 index 0000000..776e891 --- /dev/null +++ b/behavior-subject/README.md @@ -0,0 +1,62 @@ +# @observable/behavior-subject + +A variant of [`Subject`](https://jsr.io/@observable/core/doc/~/Subject) that keeps track of its +current value and replays it to [`consumers`](https://jsr.io/@observable/core#consumer) upon +[`subscription`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { BehaviorSubject } from "@observable/behavior-subject"; + +const subject = new BehaviorSubject(0); +const controller = new AbortController(); + +subject.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: () => console.error("throw"), +}); + +// Console output: +// "next" 0 + +subject.next(1); + +// Console output: +// "next" 1 + +subject.return(); + +// Console output: +// "return" + +subject.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: () => console.error("throw"), +}); + +// Console output: +// "next" 1 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/behavior-subject/deno.json b/behavior-subject/deno.json new file mode 100644 index 0000000..4620440 --- /dev/null +++ b/behavior-subject/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/behavior-subject", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/behavior-subject.test.ts b/behavior-subject/mod.test.ts similarity index 97% rename from common/behavior-subject.test.ts rename to behavior-subject/mod.test.ts index 51504f6..8a41b37 100644 --- a/common/behavior-subject.test.ts +++ b/behavior-subject/mod.test.ts @@ -1,9 +1,8 @@ import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; -import { Observable, Observer } from "@xan/observable-core"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { pipe } from "./pipe.ts"; -import { BehaviorSubject, isBehaviorSubject } from "./behavior-subject.ts"; +import { Observable, Observer } from "@observable/core"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { pipe } from "@observable/pipe"; +import { BehaviorSubject, isBehaviorSubject } from "./mod.ts"; Deno.test("BehaviorSubject.value should return current value", () => { // Arrange diff --git a/common/behavior-subject.ts b/behavior-subject/mod.ts similarity index 61% rename from common/behavior-subject.ts rename to behavior-subject/mod.ts index f92ead0..5c05dc4 100644 --- a/common/behavior-subject.ts +++ b/behavior-subject/mod.ts @@ -1,21 +1,71 @@ -import { isObserver, isSubject, Observer, type Subject } from "@xan/observable-core"; +import { isObserver, isSubject, Observer, type Subject } from "@observable/core"; import { InstanceofError, MinimumArgumentsRequiredError, ParameterTypeError, -} from "@xan/observable-internal"; -import { pipe } from "./pipe.ts"; -import { take } from "./take.ts"; -import { ReplaySubject } from "./replay-subject.ts"; -import type { BehaviorSubjectConstructor } from "./behavior-subject-constructor.ts"; +} from "@observable/internal"; +import { pipe } from "@observable/pipe"; +import { take } from "@observable/take"; +import { ReplaySubject } from "@observable/replay-subject"; /** - * Object type that acts as a variant of [`Subject`](https://jsr.io/@xan/observable-core/doc/~/Subject). + * Object type that acts as a variant of [`Subject`](https://jsr.io/@observable/core/doc/~/Subject). */ export type BehaviorSubject = & Subject & Readonly>; +/** + * Object interface for a {@linkcode BehaviorSubject} factory. + */ +export interface BehaviorSubjectConstructor { + /** + * Creates and returns an object that acts as a [`Subject`](https://jsr.io/@observable/core/doc/~/Subject) that keeps track of it's current + * value and replays it to [`consumers`](https://jsr.io/@observable/core#consumer) upon + * [`subscription`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + * @example + * ```ts + * import { BehaviorSubject } from "@observable/behavior-subject"; + * + * const subject = new BehaviorSubject(0); + * const controller = new AbortController(); + * + * subject.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: () => console.error("throw"), + * }); + * + * // Console output: + * // "next" 0 + * + * subject.next(1); + * + * // Console output: + * // "next" 1 + * + * subject.return(); + * + * // Console output: + * // "return" + * + * subject.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: () => console.error("throw"), + * }); + * + * // Console output: + * // "next" 1 + * // "return" + * ``` + */ + new (value: Value): BehaviorSubject; + readonly prototype: BehaviorSubject; +} + export const BehaviorSubject: BehaviorSubjectConstructor = class { readonly [Symbol.toStringTag] = "BehaviorSubject"; readonly #subject = new ReplaySubject(1); @@ -67,7 +117,7 @@ Object.freeze(BehaviorSubject.prototype); * Checks if a {@linkcode value} is an object that implements the {@linkcode BehaviorSubject} interface. * @example * ```ts - * import { isBehaviorSubject, BehaviorSubject } from "@xan/observable-common"; + * import { isBehaviorSubject, BehaviorSubject } from "@observable/behavior-subject"; * * const subject = new BehaviorSubject(0); * @@ -75,7 +125,7 @@ Object.freeze(BehaviorSubject.prototype); * ``` * @example * ```ts - * import { isBehaviorSubject, BehaviorSubject } from "@xan/observable-common"; + * import { isBehaviorSubject, BehaviorSubject } from "@observable/behavior-subject"; * * const custom: BehaviorSubject = { * value: 0, diff --git a/broadcast-subject/README.md b/broadcast-subject/README.md new file mode 100644 index 0000000..21ea4d6 --- /dev/null +++ b/broadcast-subject/README.md @@ -0,0 +1,57 @@ +# @observable/broadcast-subject + +A variant of [`Subject`](https://jsr.io/@observable/core/doc/~/Subject). When values are +[`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed, they are +[`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone)d and sent only +to [consumers](https://jsr.io/@observable/core#consumer) of _other_ `BroadcastSubject` instances +with the same name even if they are in different browsing contexts (e.g. browser tabs). Logically, +[consumers](https://jsr.io/@observable/core#consumer) of the `BroadcastSubject` do not receive its +_own_ [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { BroadcastSubject } from "@observable/broadcast-subject"; + +// Setup subjects +const name = "test"; +const controller = new AbortController(); +const subject1 = new BroadcastSubject(name); +const subject2 = new BroadcastSubject(name); + +// Subscribe to subjects +subject1.subscribe({ + signal: controller.signal, + next: (value) => console.log("subject1 received", value, "from subject1"), + return: () => console.log("subject1 returned"), + throw: (value) => console.log("subject1 threw", value), +}); +subject2.subscribe({ + signal: controller.signal, + next: (value) => console.log("subject2 received", value, "from subject2"), + return: () => console.log("subject2 returned"), + throw: (value) => console.log("subject2 threw", value), +}); + +subject1.next(1); // subject2 received 1 from subject1 +subject2.next(2); // subject1 received 2 from subject2 +subject2.return(); // subject2 returned +subject1.next(3); // No console output since subject2 is already returned +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/broadcast-subject/deno.json b/broadcast-subject/deno.json new file mode 100644 index 0000000..b2620df --- /dev/null +++ b/broadcast-subject/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/broadcast-subject", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/web/broadcast-subject.test.ts b/broadcast-subject/mod.test.ts similarity index 97% rename from web/broadcast-subject.test.ts rename to broadcast-subject/mod.test.ts index 09a4f0a..7f6dc61 100644 --- a/web/broadcast-subject.test.ts +++ b/broadcast-subject/mod.test.ts @@ -1,8 +1,10 @@ -import { Observer } from "@xan/observable-core"; -import { materialize, type ObserverNotification, of, pipe } from "@xan/observable-common"; -import { BroadcastSubject } from "./broadcast-subject.ts"; +import { Observer } from "@observable/core"; +import { of } from "@observable/of"; +import { BroadcastSubject } from "./mod.ts"; import { assertEquals, assertInstanceOf, assertStrictEquals, assertThrows } from "@std/assert"; -import { noop } from "@xan/observable-internal"; +import { noop } from "@observable/internal"; +import { pipe } from "@observable/pipe"; +import { materialize, type ObserverNotification } from "@observable/materialize"; Deno.test( "BroadcastSubject.toString should be '[object BroadcastSubject]'", diff --git a/web/broadcast-subject.ts b/broadcast-subject/mod.ts similarity index 50% rename from web/broadcast-subject.ts rename to broadcast-subject/mod.ts index 0c78e12..f2196f4 100644 --- a/web/broadcast-subject.ts +++ b/broadcast-subject/mod.ts @@ -1,16 +1,60 @@ -import { isObserver, type Observer, Subject } from "@xan/observable-core"; +import { isObserver, type Observer, Subject } from "@observable/core"; import { InstanceofError, MinimumArgumentsRequiredError, ParameterTypeError, -} from "@xan/observable-internal"; -import type { BroadcastSubjectConstructor } from "./broadcast-subject-constructor.ts"; +} from "@observable/internal"; /** - * Object type that acts as a variant of [`Subject`](https://jsr.io/@xan/observable-core/doc/~/Subject). + * Object type that acts as a variant of [`Subject`](https://jsr.io/@observable/core/doc/~/Subject). */ export type BroadcastSubject = Subject; +/** + * Object interface for an {@linkcode BroadcastSubject} factory. + */ +export interface BroadcastSubjectConstructor { + /** + * Creates and returns a variant of [`Subject`](https://jsr.io/@xan/subject/doc/~/Subject) whose [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed + * values are [`structured cloned`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) and sent only to [consumers](https://jsr.io/@observable/core#consumer) + * of _other_ {@linkcode BroadcastSubject} instances with the same {@linkcode name} even if they are in different browsing contexts (e.g. browser tabs). Logically, + * [consumers](https://jsr.io/@observable/core#consumer) of the {@linkcode BroadcastSubject} do not receive it's _own_ + * [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values. + * @example + * ```ts + * import { BroadcastSubject } from "@observable/broadcast-subject"; + * + * // Setup subjects + * const name = "test"; + * const controller = new AbortController(); + * const subject1 = new BroadcastSubject(name); + * const subject2 = new BroadcastSubject(name); + * + * // Subscribe to subjects + * subject1.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("subject1 received", value, "from subject1"), + * return: () => console.log("subject1 returned"), + * throw: (value) => console.log("subject1 threw", value), + * }); + * subject2.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("subject2 received", value, "from subject2"), + * return: () => console.log("subject2 returned"), + * throw: (value) => console.log("subject2 threw", value), + * }); + * + * subject1.next(1); // subject2 received 1 from subject1 + * subject2.next(2); // subject1 received 2 from subject2 + * subject2.return(); // subject2 returned + * subject1.next(3); // No console output since subject2 is already returned + * ``` + */ + new (name: string): BroadcastSubject; + new (name: string): BroadcastSubject; + readonly prototype: BroadcastSubject; +} + /** * A fixed UUID that is used to scope the name of the underlying {@linkcode BroadcastChannel}. This helps ensure that our * {@linkcode BroadcastSubject}'s only communicate with other {@linkcode BroadcastSubject}'s from this library. diff --git a/catch-error/README.md b/catch-error/README.md new file mode 100644 index 0000000..bbad814 --- /dev/null +++ b/catch-error/README.md @@ -0,0 +1,46 @@ +# @observable/catch-error + +Catches errors from the [source](https://jsr.io/@observable/core#source) +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) and returns a new +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) with the resolved value. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { catchError } from "@observable/catch-error"; +import { throwError } from "@observable/throw-error"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe( + throwError(new Error("error")), + catchError(() => of(["fallback"])), +).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" "fallback" +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/catch-error/deno.json b/catch-error/deno.json new file mode 100644 index 0000000..fd8b720 --- /dev/null +++ b/catch-error/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/catch-error", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/catch-error/mod.test.ts b/catch-error/mod.test.ts new file mode 100644 index 0000000..9a809d9 --- /dev/null +++ b/catch-error/mod.test.ts @@ -0,0 +1,307 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { Observable, Observer, Subject } from "@observable/core"; +import { pipe } from "@observable/pipe"; +import { of } from "@observable/of"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { catchError } from "./mod.ts"; +import { throwError } from "@observable/throw-error"; +import { flat } from "@observable/flat"; + +Deno.test("catchError should catch errors and emit values from resolver", () => { + // Arrange + const error = new Error("test error"); + const notifications: Array> = []; + const source = flat([of([1, 2]), throwError(error)]); + const materialized = pipe( + source, + catchError(() => of(["recovered"])), + materialize(), + ); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", "recovered"], + ["return"], + ]); +}); + +Deno.test("catchError should pass error value to resolver", () => { + // Arrange + const error = new Error("specific error"); + let receivedError: unknown; + const notifications: Array> = []; + const source = throwError(error); + const materialized = pipe( + source, + catchError((err) => { + receivedError = err; + return of(["handled"]); + }), + materialize(), + ); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(receivedError, error); + assertEquals(notifications, [["next", "handled"], ["return"]]); +}); + +Deno.test("catchError should propagate error from resolved observable", () => { + // Arrange + const originalError = new Error("original"); + const resolvedError = new Error("from resolved"); + const notifications: Array> = []; + const source = throwError(originalError); + const materialized = pipe( + source, + catchError(() => throwError(resolvedError)), + materialize(), + ); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["throw", resolvedError]]); +}); + +Deno.test("catchError should pass through values if no error occurs", () => { + // Arrange + const notifications: Array> = []; + const source = of([1, 2, 3]); + const materialized = pipe( + source, + catchError(() => of([999])), + materialize(), + ); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", 3], + ["return"], + ]); +}); + +Deno.test("catchError should pass through return", () => { + // Arrange + const notifications: Array> = []; + const source = of([]); + const materialized = pipe( + source, + catchError(() => of([999])), + materialize(), + ); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["return"]]); +}); + +Deno.test("catchError should honor unsubscribe", () => { + // Arrange + const controller = new AbortController(); + const notifications: Array> = []; + const source = of([1, 2, 3, 4, 5]); + const materialized = pipe( + source, + catchError(() => of([999])), + materialize(), + ); + + // Act + materialized.subscribe( + new Observer({ + signal: controller.signal, + next: (notification) => { + notifications.push(notification); + if (notification[0] === "next" && notification[1] === 2) { + controller.abort(); + } + }, + }), + ); + + // Assert + assertEquals(notifications, [["next", 1], ["next", 2]]); +}); + +Deno.test("catchError should honor unsubscribe during error handling", () => { + // Arrange + const controller = new AbortController(); + const error = new Error("test"); + const notifications: Array> = []; + const source = new Observable((observer) => { + observer.next(1); + observer.throw(error); + }); + const recoverySource = new Observable((observer) => { + for (const value of [10, 20, 30]) { + observer.next(value); + if (observer.signal.aborted) return; + } + observer.return(); + }); + const materialized = pipe( + source, + catchError(() => recoverySource), + materialize(), + ); + + // Act + materialized.subscribe( + new Observer({ + signal: controller.signal, + next: (notification) => { + notifications.push(notification); + if (notification[0] === "next" && notification[1] === 10) { + controller.abort(); + } + }, + }), + ); + + // Assert + assertEquals(notifications, [["next", 1], ["next", 10]]); +}); + +Deno.test("catchError should throw when called with no arguments", () => { + // Arrange / Act / Assert + assertThrows( + () => catchError(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + +Deno.test("catchError should throw when resolver is not a function", () => { + // Arrange / Act / Assert + assertThrows( + // deno-lint-ignore no-explicit-any + () => catchError(1 as any), + TypeError, + "Parameter 1 is not of type 'Function'", + ); +}); + +Deno.test("catchError should throw when called with no source", () => { + // Arrange + const operator = catchError(() => of([1])); + + // Act / Assert + assertThrows( + () => operator(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + +Deno.test("catchError should throw when source is not an Observable", () => { + // Arrange + const operator = catchError(() => of([1])); + + // Act / Assert + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(1 as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); +}); + +Deno.test("catchError should work with Subject", () => { + // Arrange + const error = new Error("subject error"); + const notifications: Array> = []; + const source = new Subject(); + const materialized = pipe( + source, + catchError(() => of(["caught"])), + materialize(), + ); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + source.next(1); + source.next(2); + source.throw(error); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", "caught"], + ["return"], + ]); +}); + +Deno.test("catchError should allow re-throwing different error", () => { + // Arrange + const originalError = new Error("original"); + const newError = new Error("new error"); + const notifications: Array> = []; + const source = throwError(originalError); + const materialized = pipe( + source, + catchError(() => throwError(newError)), + materialize(), + ); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["throw", newError]]); +}); + +Deno.test("catchError should emit multiple values from recovery observable", () => { + // Arrange + const error = new Error("test"); + const notifications: Array> = []; + const source = throwError(error); + const materialized = pipe( + source, + catchError(() => of([10, 20, 30])), + materialize(), + ); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 10], + ["next", 20], + ["next", 30], + ["return"], + ]); +}); diff --git a/catch-error/mod.ts b/catch-error/mod.ts new file mode 100644 index 0000000..e30ae94 --- /dev/null +++ b/catch-error/mod.ts @@ -0,0 +1,51 @@ +import { isObservable, Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; + +/** + * Catches errors from the [source](https://jsr.io/@observable/core#source) + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) and returns a new + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) with the resolved value. + * @example + * ```ts + * import { catchError } from "@observable/catch-error"; + * import { throwError } from "@observable/throw-error"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * + * const controller = new AbortController(); + * pipe( + * throwError(new Error("error")), + * catchError(() => of(["fallback"])), + * ).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "next" "fallback" + * // "return" + * ``` + */ +export function catchError( + resolver: (value: unknown) => Observable, +): (source: Observable) => Observable { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (typeof resolver !== "function") { + throw new ParameterTypeError(0, "Function"); + } + return function catchErrorFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + source = toObservable(source); + return new Observable((observer) => + source.subscribe({ + signal: observer.signal, + next: (value) => observer.next(value), + return: () => observer.return(), + throw: (value) => toObservable(resolver(value)).subscribe(observer), + }) + ); + }; +} diff --git a/common/README.md b/common/README.md deleted file mode 100644 index 594bdfd..0000000 --- a/common/README.md +++ /dev/null @@ -1,101 +0,0 @@ -Platform-agnostic utilities for [@xan/observable-core](https://jsr.io/@xan/observable-core). - -## Example - -```ts -import { Observable, type Observer } from "@xan/observable-core"; -import { - all, - asObservable, - BehaviorSubject, - defer, - drop, - filter, - map, - of, - pipe, - switchMap, - takeUntil, - throwError, -} from "@xan/observable-common"; - -type Customer = Readonly>; -type AuthState = Readonly>; - -class CustomerService implements Observable { - readonly #authState = new BehaviorSubject(null); - readonly authenticated = pipe(this.#authState, map(Boolean)); - - readonly #events = new Subject<"logged-in" | "logged-out" | "logging-out">(); - readonly events = pipe(this.#events, asObservable()); - - readonly #customer = pipe( - this.authenticated, - switchMap((authenticated) => { - if (authenticated) return throwError(new Error("Not authenticated")); - return pipe(this.#authState, switchMap(this.#get)); - }), - takeUntil(this.events.filter((event) => event === "logging-out")), - ); - - login(email: string, password: string): Observable { - return pipe( - defer(() => { - this.logout(); - return this.#login(email, password); - }), - map((state) => { - this.#authState.next(state); - this.#events.next("logged-in"); - }), - ); - } - - logout(): boolean { - const { value: state } = this.#authState; - if (state === null) return false; - this.#events.next("logging-out"); - this.#authState.next(null); - this.#events.next("logged-out"); - return true; - } - - subscribe(observer: Observer): void { - return this.#customer; - } - - #get(this: void, state: AuthState): Observable { - return new Observable(async (observer) => { - try { - const response = await fetch( - `https://api.example.com/customer/${state.id}`, - { headers: { Authorization: `Bearer ${state.jwt}` } }, - ); - observer.next(await response.json()); - observer.return(); - } catch (error) { - observer.throw(error); - } - }); - } - - #login(this: void, email: string, password: string): Observable { - return new Observable(async (observer) => { - try { - const response = await fetch(`https://api.example.com/login`, { - method: "POST", - body: JSON.stringify({ email, password }), - }); - observer.next(await response.json()); - observer.return(); - } catch (error) { - observer.throw(error); - } - }); - } -} -``` - -# Glossary And Semantics - -[@xan/observable-core](https://jsr.io/@xan/observable-core#glossary-and-semantics) diff --git a/common/all.ts b/common/all.ts deleted file mode 100644 index 3dd67fb..0000000 --- a/common/all.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { type Observable, Observer, Subject } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@xan/observable-internal"; -import { defer } from "./defer.ts"; -import { empty } from "./empty.ts"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { tap } from "./tap.ts"; -import { map } from "./map.ts"; -import { mergeMap } from "./merge-map.ts"; -import { filter } from "./filter.ts"; -import { takeUntil } from "./take-until.ts"; - -/** - * Creates and returns an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) whose - * [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed values are calculated from the latest - * [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed values of each of its {@linkcode sources}. - * If any of the {@linkcode sources} are {@linkcode empty}, the returned [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) - * will also be {@linkcode empty}. - */ -export function all>( - sources: Readonly<{ [Key in keyof Values]: Observable }>, -): Observable; -export function all( - // Long term, it would be nice to be able to accept an Iterable for performance and flexibility. - // This new signature would have to work in conjunction with the mapped array signature above as this - // encourages more explicit types for sources as a tuple. - sources: ReadonlyArray, -): Observable> { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (!Array.isArray(sources)) throw new ParameterTypeError(0, "Array"); - if (sources.length === 0) return empty; - return defer(() => { - let receivedFirstValueCount = 0; - const { length: expectedFirstValueCount } = sources; - const values: Array = []; - const emptySourceNotifier = new Subject(); - return pipe( - of(sources), - mergeMap((source, index) => { - let isEmpty = true; - return pipe( - source, - tap( - new Observer({ - next: processNextValue, - return: processReturn, - throw: noop, - }), - ), - ); - - function processNextValue(value: unknown): void { - if (isEmpty) receivedFirstValueCount++; - isEmpty = false; - values[index] = value; - } - - function processReturn(): void { - if (isEmpty) emptySourceNotifier.next(); - } - }), - filter(() => receivedFirstValueCount === expectedFirstValueCount), - map(() => values.slice()), - takeUntil(emptySourceNotifier), - ); - }); -} diff --git a/common/as-observable.test.ts b/common/as-observable.test.ts deleted file mode 100644 index c6aca79..0000000 --- a/common/as-observable.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Observable, Observer } from "@xan/observable-core"; -import { assertEquals, assertInstanceOf, assertStrictEquals } from "@std/assert"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; -import { of } from "./of.ts"; - -Deno.test( - "asObservable should convert a custom observable to a proper observable", - () => { - // Arrange - const observer = new Observer(); - const subscribeCalls: Array["subscribe"]>> = []; - const custom: Observable = { - subscribe(observer) { - assertInstanceOf(observer, Observer); - subscribeCalls.push([observer]); - observer.next(1); - observer.next(2); - observer.return(); - }, - }; - - // Act - const observable = pipe(custom, asObservable()); - observable.subscribe(observer); - - // Assert - assertInstanceOf(observable, Observable); - assertEquals(subscribeCalls, [[observer]]); - }, -); - -Deno.test( - "asObservable should return the same observer if it is already a proper observer", - () => { - // Arrange - const expected = of([1, 2]); - - // Act - const actual = pipe(expected, asObservable()); - - // Assert - assertStrictEquals(actual, expected); - }, -); diff --git a/common/as-observable.ts b/common/as-observable.ts deleted file mode 100644 index 143a3ca..0000000 --- a/common/as-observable.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import { isObservable, type Observable, toObservable } from "@xan/observable-core"; - -/** - * Converts a custom [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) to a proper - * [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable). If the [source](https://jsr.io/@xan/observable-core#source) is - * already an instanceof [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) (which means it has - * [`Observable.prototype`](https://jsr.io/@xan/observable-core/doc/~/ObservableConstructor.prototype) in its prototype chain), - * it's returned directly. Otherwise, a new [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) object is created - * that wraps the original [source](https://jsr.io/@xan/observable-core#source). - */ -export function asObservable(): ( - source: Observable, -) => Observable { - return function asObservableFn(source) { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - return toObservable(source); - }; -} diff --git a/common/async-subject-constructor.ts b/common/async-subject-constructor.ts deleted file mode 100644 index deb15a7..0000000 --- a/common/async-subject-constructor.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AsyncSubject } from "./async-subject.ts"; - -/** - * Object interface for an {@linkcode AsyncSubject} factory. - */ -export interface AsyncSubjectConstructor { - /** - * Creates and returns an object that acts as a [`Subject`](https://jsr.io/@xan/observable-core/doc/~/Subject) that buffers the most recent - * [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed value until [`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return) is called. - * Once [`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return)ed, [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next) will be replayed - * to late [`consumers`](https://jsr.io/@xan/observable-core/doc/~/Observable.subscribe) upon [`subscription`](https://jsr.io/@xan/observable-core/doc/~/Observable.subscribe). - * @example - * ```ts - * import { AsyncSubject } from "@xan/observable-common"; - * import { Observer } from "@xan/observable-core"; - * - * const subject = new AsyncSubject(); - * subject.next(1); - * subject.next(2); - * - * subject.subscribe(new Observer((value) => console.log(value))); - * - * subject.next(3); - * - * subject.return(); // Console output: 3 - * - * subject.subscribe(new Observer((value) => console.log(value))); // Console output: 3 - * ``` - */ - new (): AsyncSubject; - new (): AsyncSubject; - readonly prototype: AsyncSubject; -} diff --git a/common/async-subject.ts b/common/async-subject.ts deleted file mode 100644 index fd207df..0000000 --- a/common/async-subject.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { isObserver, type Observer, type Subject } from "@xan/observable-core"; -import { - InstanceofError, - MinimumArgumentsRequiredError, - ParameterTypeError, -} from "@xan/observable-internal"; -import { flat } from "./flat.ts"; -import { pipe } from "./pipe.ts"; -import { ignoreElements } from "./ignore-elements.ts"; -import { ReplaySubject } from "./replay-subject.ts"; -import type { AsyncSubjectConstructor } from "./async-subject-constructor.ts"; - -/** - * Object type that acts as a variant of [`Subject`](https://jsr.io/@xan/observable-core/doc/~/Subject). - */ -export type AsyncSubject = Subject; - -export const AsyncSubject: AsyncSubjectConstructor = class { - readonly [Symbol.toStringTag] = "AsyncSubject"; - readonly #subject = new ReplaySubject(1); - readonly signal = this.#subject.signal; - readonly #observable = flat([ - pipe(this.#subject, ignoreElements()), - this.#subject, - ]); - - constructor() { - Object.freeze(this); - } - - next(value: unknown): void { - if (this instanceof AsyncSubject) this.#subject.next(value); - else throw new InstanceofError("this", "AsyncSubject"); - } - - return(): void { - if (this instanceof AsyncSubject) this.#subject.return(); - else throw new InstanceofError("this", "AsyncSubject"); - } - - throw(value: unknown): void { - if (this instanceof AsyncSubject) { - this.#subject.next(undefined); - this.#subject.throw(value); - } else throw new InstanceofError("this", "AsyncSubject"); - } - - subscribe(observer: Observer): void { - if (!(this instanceof AsyncSubject)) { - throw new InstanceofError("this", "AsyncSubject"); - } - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (!isObserver(observer)) throw new ParameterTypeError(0, "Observer"); - this.#observable.subscribe(observer); - } -}; - -Object.freeze(AsyncSubject); -Object.freeze(AsyncSubject.prototype); diff --git a/common/behavior-subject-constructor.ts b/common/behavior-subject-constructor.ts deleted file mode 100644 index 9efecc2..0000000 --- a/common/behavior-subject-constructor.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { BehaviorSubject } from "./behavior-subject.ts"; - -/** - * Object interface for a {@linkcode BehaviorSubject} factory. - */ -export interface BehaviorSubjectConstructor { - /** - * Creates and returns an object that acts as a [`Subject`](https://jsr.io/@xan/observable-core/doc/~/Subject) that keeps track of it's current - * value and replays it to [`consumers`](https://jsr.io/@xan/observable-core#consumer) upon - * [`subscription`](https://jsr.io/@xan/observable-core/doc/~/Observable.subscribe). - * @example - * ```ts - * import { BehaviorSubject } from "@xan/observable-common"; - * - * const subject = new BehaviorSubject(0); - * const controller = new AbortController(); - * - * subject.subscribe({ - * signal: controller.signal, - * next: (value) => console.log(value), - * return: () => console.log("return"), - * throw: () => console.error("throw"), - * }); - * - * // console output: - * // 0 - * - * subject.next(1); - * - * // console output: - * // 1 - * - * subject.return(); - * - * // console output: - * // return - * - * subject.subscribe({ - * signal: controller.signal, - * next: (value) => console.log(value), - * return: () => console.log("return"), - * throw: () => console.error("throw"), - * }); - * - * // console output: - * // 1 - * // return - * ``` - */ - new (value: Value): BehaviorSubject; - readonly prototype: BehaviorSubject; -} diff --git a/common/defer.test.ts b/common/defer.test.ts deleted file mode 100644 index 5df7cde..0000000 --- a/common/defer.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { assertEquals } from "@std/assert"; -import { Observable, Observer } from "@xan/observable-core"; -import { dematerialize } from "./dematerialize.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { pipe } from "./pipe.ts"; -import { defer } from "./defer.ts"; - -Deno.test( - "defer should create an Observable that calls a factory to make an Observable for each new Observer", - () => { - // Arrange - const notifications: Array> = []; - const source = defer>( - () => - new Observable>((observer) => { - for (const value of [1, 2, 3]) { - observer.next(["next", value]); - if (observer.signal.aborted) return; - } - observer.return(); - }), - ); - const materialized = pipe(source, dematerialize(), materialize()); - - // Act - materialized.subscribe( - new Observer((notification) => notifications.push(notification)), - ); - - // Assert - assertEquals(notifications, [ - ["next", 1], - ["next", 2], - ["next", 3], - ["return"], - ]); - }, -); - -Deno.test("defer should throw an error if the factory throws an error", () => { - // Arrange - const error = new Error(Math.random().toString()); - const notifications: Array = []; - const source = defer(() => { - throw error; - }); - const materialized = pipe(source, materialize()); - - // Act - materialized.subscribe( - new Observer((notification) => notifications.push(notification)), - ); - - // Assert - assertEquals(notifications, [["throw", error]]); -}); diff --git a/common/defer.ts b/common/defer.ts deleted file mode 100644 index 30513da..0000000 --- a/common/defer.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; - -/** - * Creates an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) that, on - * [`subscribe`](https://jsr.io/@xan/observable-core/doc/~/Observable.subscribe), calls an - * [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) {@linkcode factory} to - * get an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) for each - * [`Observer`](https://jsr.io/@xan/observable-core/doc/~/Observer). - */ -export function defer( - factory: () => Observable, -): Observable { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (typeof factory !== "function") { - throw new ParameterTypeError(0, "Function"); - } - return new Observable((observer) => pipe(factory(), asObservable()).subscribe(observer)); -} diff --git a/common/dematerialize.test.ts b/common/dematerialize.test.ts deleted file mode 100644 index 8326ee2..0000000 --- a/common/dematerialize.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { assertEquals } from "@std/assert"; -import { Observable, Observer } from "@xan/observable-core"; -import { pipe } from "./pipe.ts"; -import { dematerialize } from "./dematerialize.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; - -Deno.test( - "dematerialize should convert a source Observable of ObserverNotification objects into a source Observable of the original values", - () => { - // Arrange - const source = new Observable>((observer) => { - for (const value of [1, 2, 3]) { - observer.next(["next", value]); - if (observer.signal.aborted) return; - } - observer.return(); - }); - const notifications: Array> = []; - const materialized = pipe(source, dematerialize(), materialize()); - - // Act - materialized.subscribe( - new Observer((notification) => notifications.push(notification)), - ); - - // Assert - assertEquals(notifications, [ - ["next", 1], - ["next", 2], - ["next", 3], - ["return"], - ]); - }, -); - -Deno.test("dematerialize should honor unsubscribe", () => { - // Arrange - const controller = new AbortController(); - const source = new Observable>((observer) => { - for (const value of [1, 2, 3]) { - observer.next(["next", value]); - if (observer.signal.aborted) return; - } - observer.next(["return"]); - observer.return(); - }); - - const notifications: Array> = []; - const materialized = pipe(source, dematerialize(), materialize()); - - // Act - materialized.subscribe( - new Observer({ - signal: controller.signal, - next: (notification) => { - notifications.push(notification); - if (notifications.length === 2) controller.abort(); - }, - }), - ); - - // Assert - assertEquals(notifications, [ - ["next", 1], - ["next", 2], - ]); -}); diff --git a/common/dematerialize.ts b/common/dematerialize.ts deleted file mode 100644 index eaab4ac..0000000 --- a/common/dematerialize.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { isObservable, Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; - -/** - * Converts an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) of - * {@linkcode ObserverNotification|notification} objects into the emissions - * that they represent. - */ -export function dematerialize(): ( - source: Observable>, -) => Observable { - return function dematerializeFn(source) { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - source = pipe(source, asObservable()); - return new Observable((observer) => - source.subscribe({ - signal: observer.signal, - next(value) { - switch (value[0]) { - case "next": - observer.next(value[1]); - break; - case "return": - observer.return(); - break; - case "throw": - observer.throw(value[1]); - break; - default: - observer.throw(new ParameterTypeError(0, "ObserverNotification")); - break; - } - }, - return: () => observer.return(), - throw: (value) => observer.throw(value), - }) - ); - }; -} diff --git a/common/deno.json b/common/deno.json deleted file mode 100644 index 6d8c24c..0000000 --- a/common/deno.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "@xan/observable-common", - "version": "0.8.0", - "license": "MIT", - "exports": "./mod.ts" -} diff --git a/common/empty.ts b/common/empty.ts deleted file mode 100644 index f511c48..0000000 --- a/common/empty.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Observable } from "@xan/observable-core"; -import { of } from "./of.ts"; - -/** - * An [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) that calls [`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return) - * immediately on [`subscribe`](https://jsr.io/@xan/observable-core/doc/~/Observable.subscribe). - * @example - * ```ts - * import { empty } from "@xan/observable-common"; - * - * const controller = new AbortController(); - * - * empty.subscribe({ - * signal: controller.signal, - * next: () => console.log("next"), - * throw: () => console.log("throw"), - * return: () => console.log("return"), - * }); - * - * // Console output: - * // return - * ``` - */ -export const empty: Observable = of([]); diff --git a/common/exhaust-map.ts b/common/exhaust-map.ts deleted file mode 100644 index e5176c2..0000000 --- a/common/exhaust-map.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { isObservable, type Observable, Observer } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@xan/observable-internal"; -import { defer } from "./defer.ts"; -import { pipe } from "./pipe.ts"; -import { tap } from "./tap.ts"; -import { filter } from "./filter.ts"; -import { switchMap } from "./switch-map.ts"; - -/** - * {@linkcode project|Projects} each source value to an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) - * which is merged in the output [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) only if the previous - * {@linkcode project|projected} [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) has - * [`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return)ed. - */ -export function exhaustMap( - project: (value: In, index: number) => Observable, -): (source: Observable) => Observable { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (typeof project !== "function") { - throw new ParameterTypeError(0, "Function"); - } - return function exhaustMapFn(source) { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - return defer(() => { - let activeInnerSubscription = false; - return pipe( - source, - filter(() => !activeInnerSubscription), - switchMap((value, index) => { - activeInnerSubscription = true; - return pipe( - project(value, index), - tap(new Observer({ return: processReturn, throw: noop })), - ); - - function processReturn(): void { - activeInnerSubscription = false; - } - }), - ); - }); - }; -} diff --git a/common/finalize.ts b/common/finalize.ts deleted file mode 100644 index 4393689..0000000 --- a/common/finalize.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { isObservable, Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; - -/** - * The [producer](https://jsr.io/@xan/observable-core#producer) is notifying the [consumer](https://jsr.io/@xan/observable-core#consumer) - * that it's done [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ing, values for any reason, and will send no more values. Finalization, - * if it occurs, will always happen as a side-effect _after_ [`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return), - * [`throw`](https://jsr.io/@xan/observable-core/doc/~/Observer.throw), or [`unsubscribe`](https://jsr.io/@xan/observable-core/doc/~/Observer.signal) (whichever comes last). - */ -export function finalize( - finalizer: () => void, -): (source: Observable) => Observable { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (typeof finalizer !== "function") { - throw new ParameterTypeError(0, "Function"); - } - return function finalizeFn(source) { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - source = pipe(source, asObservable()); - return new Observable((observer) => { - const observerAbortListenerController = new AbortController(); - observer.signal.addEventListener("abort", () => finalizer(), { - once: true, - signal: observerAbortListenerController.signal, - }); - source.subscribe({ - signal: observer.signal, - next: (value) => observer.next(value), - return() { - observerAbortListenerController.abort(); - observer.return(); - finalizer(); - }, - throw(value) { - observerAbortListenerController.abort(); - observer.throw(value); - finalizer(); - }, - }); - }); - }; -} diff --git a/common/merge.ts b/common/merge.ts deleted file mode 100644 index c2273c2..0000000 --- a/common/merge.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Observable } from "@xan/observable-core"; -import { - identity, - isIterable, - MinimumArgumentsRequiredError, - ParameterTypeError, -} from "@xan/observable-internal"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { mergeMap } from "./merge-map.ts"; - -/** - * Creates and returns an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) which concurrently - * [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)s all values from every given {@linkcode sources|source}. - */ -export function merge( - // Accepting an Iterable is a design choice for performance (iterables are lazily evaluated) and - // flexibility (can accept any iterable, not just arrays). - sources: Iterable>, -): Observable { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (!isIterable(sources)) throw new ParameterTypeError(0, "Iterable"); - return pipe(of(sources), mergeMap(identity)); -} diff --git a/common/mod.test.ts b/common/mod.test.ts deleted file mode 100644 index 447c3b9..0000000 --- a/common/mod.test.ts +++ /dev/null @@ -1,4 +0,0 @@ -Deno.test("mod should be importable", async () => { - // Arrange / Act / Assert - await import("./mod.ts"); -}); diff --git a/common/mod.ts b/common/mod.ts deleted file mode 100644 index 2652d72..0000000 --- a/common/mod.ts +++ /dev/null @@ -1,33 +0,0 @@ -export { all } from "./all.ts"; -export { asObservable } from "./as-observable.ts"; -export type { AsyncSubjectConstructor } from "./async-subject-constructor.ts"; -export { AsyncSubject } from "./async-subject.ts"; -export type { BehaviorSubjectConstructor } from "./behavior-subject-constructor.ts"; -export { BehaviorSubject } from "./behavior-subject.ts"; -export { defer } from "./defer.ts"; -export { dematerialize } from "./dematerialize.ts"; -export { drop } from "./drop.ts"; -export { empty } from "./empty.ts"; -export { exhaustMap } from "./exhaust-map.ts"; -export { filter } from "./filter.ts"; -export { finalize } from "./finalize.ts"; -export { flatMap } from "./flat-map.ts"; -export { flat } from "./flat.ts"; -export { ignoreElements } from "./ignore-elements.ts"; -export { map } from "./map.ts"; -export { materialize } from "./materialize.ts"; -export { mergeMap } from "./merge-map.ts"; -export { merge } from "./merge.ts"; -export { never } from "./never.ts"; -export type { ObserverNotification } from "./observer-notification.ts"; -export { of } from "./of.ts"; -export { pipe } from "./pipe.ts"; -export { race } from "./race.ts"; -export type { ReplaySubjectConstructor } from "./replay-subject-constructor.ts"; -export { ReplaySubject } from "./replay-subject.ts"; -export { switchMap } from "./switch-map.ts"; -export { takeUntil } from "./take-until.ts"; -export { take } from "./take.ts"; -export { tap } from "./tap.ts"; -export { throwError } from "./throw-error.ts"; -export { timer } from "./timer.ts"; diff --git a/common/never.ts b/common/never.ts deleted file mode 100644 index e456504..0000000 --- a/common/never.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Observable } from "@xan/observable-core"; -import { noop } from "@xan/observable-internal"; - -/** - * An [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) that does nothing on - * [`subscribe`](https://jsr.io/@xan/observable-core/doc/~/Observable.subscribe). - * @example - * ```ts - * import { never } from "@xan/observable-common"; - * - * const controller = new AbortController(); - * - * never.subscribe({ - * signal: controller.signal, - * next: () => console.log("next"), // Never called - * throw: () => console.log("throw"), // Never called - * return: () => console.log("return"), // Never called - * }); - * ``` - */ -export const never: Observable = new Observable(noop); diff --git a/common/observer-notification.ts b/common/observer-notification.ts deleted file mode 100644 index cda5012..0000000 --- a/common/observer-notification.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Observer } from "@xan/observable-core"; - -/** - * Represents any type of [`Observer`](https://jsr.io/@xan/observable-core/doc/~/Observer) notification - * ([`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next), - * [`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return), or - * [`throw`](https://jsr.io/@xan/observable-core/doc/~/Observer.throw)). - */ -export type ObserverNotification = Readonly< - | [type: Extract<"next", keyof Observer>, value: Value] - | [type: Extract<"return", keyof Observer>] - | [type: Extract<"throw", keyof Observer>, value: unknown] ->; diff --git a/common/race.ts b/common/race.ts deleted file mode 100644 index 52fa8e3..0000000 --- a/common/race.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { type Observable, Observer } from "@xan/observable-core"; -import { - isIterable, - MinimumArgumentsRequiredError, - noop, - ParameterTypeError, -} from "@xan/observable-internal"; -import { defer } from "./defer.ts"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { tap } from "./tap.ts"; -import { mergeMap } from "./merge-map.ts"; -import { takeUntil } from "./take-until.ts"; -import { filter } from "./filter.ts"; -import { AsyncSubject } from "./async-subject.ts"; - -/** - * Creates and returns an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) that mirrors the first {@linkcode sources|source} - * [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) to [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next) or - * [`throw`](https://jsr.io/@xan/observable-core/doc/~/Observer.throw) a value. - */ -export function race( - // Accepting an Iterable is a design choice for performance (iterables are lazily evaluated) and - // flexibility (can accept any iterable, not just arrays). - sources: Iterable>, -): Observable { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (!isIterable(sources)) throw new ParameterTypeError(0, "Iterable"); - return defer(() => { - const finished = new AsyncSubject(); - return pipe( - of(sources), - takeUntil(finished), - mergeMap((source, index) => { - const observer = new Observer({ next: finish, throw: noop }); - const lost = pipe(finished, filter(isLoser)); - return pipe(source, tap(observer), takeUntil(lost)); - - function finish(): void { - finished.next(index); - finished.return(); - } - - function isLoser(winnerIndex: number): boolean { - return winnerIndex !== index; - } - }), - ); - }); -} diff --git a/common/replay-subject-constructor.ts b/common/replay-subject-constructor.ts deleted file mode 100644 index d875f8b..0000000 --- a/common/replay-subject-constructor.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { ReplaySubject } from "./replay-subject.ts"; - -/** - * Object interface for an {@linkcode ReplaySubject} factory. - */ -export interface ReplaySubjectConstructor { - /** - * Creates and returns an object that acts as a [`Subject`](https://jsr.io/@xan/observable-core/doc/~/Subject) that replays - * buffered [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed values upon - * [`subscription`](https://jsr.io/@xan/observable-core/doc/~/Observable.subscribe). - * @example - * ```ts - * import { ReplaySubject } from "@xan/observable-common"; - * - * const subject = new ReplaySubject(3); - * const controller = new AbortController(); - * - * subject.next(1); // Stored in buffer - * subject.next(2); // Stored in buffer - * subject.next(3); // Stored in buffer - * subject.next(4); // Stored in buffer and 1 gets trimmed off - * - * subject.subscribe({ - * signal: controller.signal, - * next: (value) => console.log(value), - * return: () => console.log("return"), - * throw: (value) => console.log("throw", value), - * }); - * - * // Console output: - * // 2 - * // 3 - * // 4 - * - * // Values pushed after the subscribe will emit immediately - * // unless the subject is already finalized. - * subject.next(5); // Stored in buffer and 2 gets trimmed off - * - * // Console output: - * // 5 - * - * subject.subscribe({ - * signal: controller.signal, - * next: (value) => console.log(value), - * return: () => console.log("return"), - * throw: (value) => console.log("throw", value), - * }); - * - * // Console output: - * // 3 - * // 4 - * // 5 - * ``` - */ - new (bufferSize: number): ReplaySubject; - readonly prototype: ReplaySubject; -} diff --git a/common/switch-map.ts b/common/switch-map.ts deleted file mode 100644 index 006657a..0000000 --- a/common/switch-map.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { isObservable, type Observable, Observer, Subject } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@xan/observable-internal"; -import { defer } from "./defer.ts"; -import { pipe } from "./pipe.ts"; -import { takeUntil } from "./take-until.ts"; -import { tap } from "./tap.ts"; -import { mergeMap } from "./merge-map.ts"; -import { asObservable } from "./as-observable.ts"; - -/** - * {@linkcode project|Projects} each source value to an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) which is - * merged in the output [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable), emitting values only from the most - * recently {@linkcode project|projected} [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable). - */ -export function switchMap( - project: (value: In, index: number) => Observable, -): (source: Observable) => Observable { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (typeof project !== "function") { - throw new ParameterTypeError(0, "Function"); - } - return function switchMapFn(source) { - if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); - if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - source = pipe(source, asObservable()); - return defer(() => { - const switching = new Subject(); - return pipe( - source, - tap(new Observer({ next: () => switching.next(), throw: noop })), - mergeMap((value, index) => pipe(project(value, index), takeUntil(switching))), - ); - }); - }; -} diff --git a/common/throw-error.ts b/common/throw-error.ts deleted file mode 100644 index 533a7a6..0000000 --- a/common/throw-error.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Observable } from "@xan/observable-core"; - -/** - * Creates an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) that will [`throw`](https://jsr.io/@xan/observable-core/doc/~/Observer.throw) the - * given `value` immediately upon [`subscribe`](https://jsr.io/@xan/observable-core/doc/~/Observable.subscribe). - * - * @param value The value to [`throw`](https://jsr.io/@xan/observable-core/doc/~/Observer.throw). - * @example - * ```ts - * import { throwError } from "@xan/observable-common"; - * - * const controller = new AbortController(); - * - * throwError(new Error("throw")).subscribe({ - * signal: controller.signal, - * next: (value) => console.log(value), // Never called - * return: () => console.log("return"), // Never called - * throw: (value) => console.log(value), // Called immediately - * }); - * ``` - */ -export function throwError(value: unknown): Observable { - return new Observable((observer) => observer.throw(value)); -} diff --git a/core/README.md b/core/README.md index 1e4334a..4ca4768 100644 --- a/core/README.md +++ b/core/README.md @@ -1,10 +1,10 @@ -# @xan/observable-core +# @observable/core A lightweight, [RxJS](https://rxjs.dev/)-inspired implementation of the [Observer pattern](https://refactoring.guru/design-patterns/observer) in JavaScript. Features -[`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable)'s with +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable)'s with [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)-based -[`unsubscription`](https://jsr.io/@xan/observable-core/doc/~/Observer.signal), supporting both +[`unsubscription`](https://jsr.io/@observable/core/doc/~/Observer.signal), supporting both synchronous and asynchronous [producers](#producer). ## Build @@ -23,7 +23,7 @@ Run `deno task test` or `deno task test:ci` to execute the unit tests via ## Example ```ts -import { Observable } from "@xan/observable-core"; +import { Observable } from "@observable/core"; const observable = new Observable<0>((observer) => { // Create a timeout as our producer to next a successful execution code (0) after 1 second. @@ -42,11 +42,11 @@ const observable = new Observable<0>((observer) => { # Glossary And Semantics -When discussing and documenting [`Observable`](https://jsr.io/@xan/observable-core/~/Observable)s, -it's important to have a common language and a known set of rules around what is going on. This -document is an attempt to standardize these things so we can try to control the language in our -docs, and hopefully other publications about this library, so we can discuss reactive programming -with this library on consistent terms. +When discussing and documenting [`Observable`](https://jsr.io/@observable/core/~/Observable)s, it's +important to have a common language and a known set of rules around what is going on. This document +is an attempt to standardize these things so we can try to control the language in our docs, and +hopefully other publications about this library, so we can discuss reactive programming with this +library on consistent terms. While not all of the documentation for this library reflects this terminology, it's a goal to ensure it does, and to ensure the language and names around the library use this document as a source of @@ -56,7 +56,7 @@ truth and unified language. There are high level entities that are frequently discussed. It's important to define them separately from other lower-level concepts, because they relate to the nature of -[`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable). +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable). ### Consumer @@ -87,8 +87,8 @@ A [consumer](#consumer) reacting to [producer](#producer) [notifications](#notif ### Observation Chain -When an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) uses another -[`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) as a [producer](#producer), an +When an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) uses another +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) as a [producer](#producer), an "observation chain" is set up. That is a chain of [observation](#observation) such that multiple [observers](#observation) are notifying each other in a unidirectional way toward the final [consumer](#consumer). @@ -96,10 +96,10 @@ When an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) use ### Notification The act of a [producer](#producer) pushing -[`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed values, -[`throw`](https://jsr.io/@xan/observable-core/doc/~/Observer.throw)n values, or -[`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return)s to a [consumer](#consumer) to -be [observed](#observation). +[`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values, +[`throw`](https://jsr.io/@observable/core/doc/~/Observer.throw)n values, or +[`return`](https://jsr.io/@observable/core/doc/~/Observer.return)s to a [consumer](#consumer) to be +[observed](#observation). ## Major Concepts @@ -108,29 +108,28 @@ in [push-based](#push) reactive systems. ### Cold -An [`Observable`](https://jsr.io/@xan/observable-core/~/Observable) is "cold" when it creates a new -[producer](#producer) during -[`subscribe`](https://jsr.io/@xan/observable-core/~/Observable.subscribe) for every new -[subscription](#subscription). As a result, "cold" -[`Observable`](https://jsr.io/@xan/observable-core/~/Observable)s are _always_ [unicast](#unicast), +An [`Observable`](https://jsr.io/@observable/core/~/Observable) is "cold" when it creates a new +[producer](#producer) during [`subscribe`](https://jsr.io/@observable/core/~/Observable.subscribe) +for every new [subscription](#subscription). As a result, "cold" +[`Observable`](https://jsr.io/@observable/core/~/Observable)s are _always_ [unicast](#unicast), being one [producer](#producer) [observed](#observation) by one [consumer](#consumer). Cold -[`Observable`](https://jsr.io/@xan/observable-core/~/Observable)s can be made [hot](#hot) but not -the other way around. +[`Observable`](https://jsr.io/@observable/core/~/Observable)s can be made [hot](#hot) but not the +other way around. ### Hot -An [`Observable`](https://jsr.io/@xan/observable-core/~/Observable) is "hot", when its +An [`Observable`](https://jsr.io/@observable/core/~/Observable) is "hot", when its [producer](#producer) was created outside of the context of the -[`subscribe`](https://jsr.io/@xan/observable-core/~/Observable.subscribe) action. This means that -the "hot" [`Observable`](https://jsr.io/@xan/observable-core/~/Observable) is almost always +[`subscribe`](https://jsr.io/@observable/core/~/Observable.subscribe) action. This means that the +"hot" [`Observable`](https://jsr.io/@observable/core/~/Observable) is almost always [multicast](#multicast). It is possible that a "hot" -[`Observable`](https://jsr.io/@xan/observable-core/~/Observable) is still _technically_ +[`Observable`](https://jsr.io/@observable/core/~/Observable) is still _technically_ [unicast](#unicast), if it is engineered to only allow one [subscription](#subscription) at a time, however, there is no straightforward mechanism for this in the library, and the scenario is an unlikely one. For the purposes of discussion, all "hot" -[`Observable`](https://jsr.io/@xan/observable-core/~/Observable)s can be assumed to be -[multicast](#multicast). Hot [`Observable`](https://jsr.io/@xan/observable-core/~/Observable)s -cannot be made [cold](#cold). +[`Observable`](https://jsr.io/@observable/core/~/Observable)s can be assumed to be +[multicast](#multicast). Hot [`Observable`](https://jsr.io/@observable/core/~/Observable)s cannot be +made [cold](#cold). ### Multicast @@ -144,10 +143,10 @@ The act of one [producer](#producer) being [observed](#observation) by **only on ### Push -[`Observer`](https://jsr.io/@xan/observable-core/doc/~/Observer)s are a push-based type. That means +[`Observer`](https://jsr.io/@observable/core/doc/~/Observer)s are a push-based type. That means rather than having the [consumer](#consumer) call a function or perform some other action to get a value, the [consumer](#consumer) receives values as soon as the [producer](#producer) has produced -them, via a registered [next](https://jsr.io/@xan/observable-core/doc/~/Observer.next) handler. +them, via a registered [next](https://jsr.io/@observable/core/doc/~/Observer.next) handler. ### Pull @@ -168,22 +167,22 @@ A factory function that creates an [operator function](#operator-function). ### Operator Function -A function that takes an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable), and -maps it to a new [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable). Nothing more, -nothing less. [Operator functions](#operator-function) are created by [operators](#operator). +A function that takes an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable), and maps +it to a new [`Observable`](https://jsr.io/@observable/core/doc/~/Observable). Nothing more, nothing +less. [Operator functions](#operator-function) are created by [operators](#operator). ### Operation An action taken while handling a [notification](#notification), as set up by an [operator](#operator) and/or [operator function](#operator-function). During [subscription](#subscription) to that -[`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable), [operations](#operation) are +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable), [operations](#operation) are performed in an order dictated by the [observation chain](#observation-chain). ### Stream A "stream" or "streaming" in the case of -[`observables`](https://jsr.io/@xan/observable-core/doc/~/Observable), refers to the collection of +[`observables`](https://jsr.io/@observable/core/doc/~/Observable), refers to the collection of [operations](#operation), as they are processed during a [subscription](#subscription). This is not to be confused with node Streams, and the word "stream", on its own, should be used sparingly in documentation and articles. Instead, prefer [observation chain](#observation-chain), @@ -192,32 +191,32 @@ fine to use given this defined meaning. ### Source -An [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) that will supply values to -another [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable). This -[source](#source), will be the [producer](#producer) for the resulting -[`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) and all of its +An [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that will supply values to +another [`Observable`](https://jsr.io/@observable/core/doc/~/Observable). This [source](#source), +will be the [producer](#producer) for the resulting +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) and all of its [subscriptions](#subscriptions). Sources may generally be any type of -[`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable). +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable). ### Notifier -An [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) that is being used to notify -another [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) that it needs to -perform some action. The action should only occur on a -[`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next) and _never_ on -[`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return) or -[`throw`](https://jsr.io/@xan/observable-core/doc/~/Observer.throw). +An [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that is being used to notify +another [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that it needs to perform +some action. The action should only occur on a +[`next`](https://jsr.io/@observable/core/doc/~/Observer.next) and _never_ on +[`return`](https://jsr.io/@observable/core/doc/~/Observer.return) or +[`throw`](https://jsr.io/@observable/core/doc/~/Observer.throw). ## Other Concepts ### Unhandled Errors -An "unhandled error" is any [`throw`](https://jsr.io/@xan/observable-core/doc/~/Observer.throw)n -value that is not handled by a [consumer](#consumer)-provided function, which is generally provided -during the [`subscribe`](https://jsr.io/@xan/observable-core/doc/~/Observable.subscribe) action by -constructing a new [`Observer`](https://jsr.io/@xan/observable-core/doc/~/Observer). If no -[`throw handler`](https://jsr.io/@xan/observable-core/doc/~/Observer.throw) was provided, this -library will assume the error is "unhandled" and rethrow it on a new callstack to prevent +An "unhandled error" is any [`throw`](https://jsr.io/@observable/core/doc/~/Observer.throw)n value +that is not handled by a [consumer](#consumer)-provided function, which is generally provided during +the [`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe) action by constructing +a new [`Observer`](https://jsr.io/@observable/core/doc/~/Observer). If no +[`throw handler`](https://jsr.io/@observable/core/doc/~/Observer.throw) was provided, this library +will assume the error is "unhandled" and rethrow it on a new callstack to prevent ["producer interference"](#producer-interference). ### Producer Interference diff --git a/core/deno.json b/core/deno.json index 7a087ca..65eeb4a 100644 --- a/core/deno.json +++ b/core/deno.json @@ -1,6 +1,6 @@ { - "name": "@xan/observable-core", - "version": "0.7.0", + "name": "@observable/core", + "version": "0.1.0", "license": "MIT", "exports": "./mod.ts" } diff --git a/core/is-observable.test.ts b/core/is-observable.test.ts index e1e31b5..fd7afc0 100644 --- a/core/is-observable.test.ts +++ b/core/is-observable.test.ts @@ -1,5 +1,5 @@ import { assertStrictEquals } from "@std/assert"; -import { noop } from "@xan/observable-internal"; +import { noop } from "@observable/internal"; import { isObservable } from "./is-observable.ts"; import { Observable } from "./observable.ts"; diff --git a/core/is-observable.ts b/core/is-observable.ts index e05b258..5be8004 100644 --- a/core/is-observable.ts +++ b/core/is-observable.ts @@ -1,11 +1,11 @@ -import { isObject, MinimumArgumentsRequiredError } from "@xan/observable-internal"; +import { isObject, MinimumArgumentsRequiredError } from "@observable/internal"; import { Observable } from "./observable.ts"; /** * Checks if a {@linkcode value} is an object that implements the {@linkcode Observable} interface. * @example * ```ts - * import { isObservable, Observable } from "@xan/observable-core"; + * import { isObservable, Observable } from "@observable/core"; * * const observableInstance = new Observable((observer) => { * // Implementation omitted for brevity. diff --git a/core/is-observer.test.ts b/core/is-observer.test.ts index 68f329b..3a8555d 100644 --- a/core/is-observer.test.ts +++ b/core/is-observer.test.ts @@ -1,7 +1,7 @@ import { assertStrictEquals } from "@std/assert"; import { isObserver } from "./is-observer.ts"; import type { Observer } from "./observer.ts"; -import { noop } from "@xan/observable-internal"; +import { noop } from "@observable/internal"; Deno.test("isObserver should return false if the value is null", () => { // Arrange diff --git a/core/is-observer.ts b/core/is-observer.ts index e40faa3..d0f1d49 100644 --- a/core/is-observer.ts +++ b/core/is-observer.ts @@ -1,11 +1,11 @@ -import { isAbortSignal, isObject, MinimumArgumentsRequiredError } from "@xan/observable-internal"; +import { isAbortSignal, isObject, MinimumArgumentsRequiredError } from "@observable/internal"; import { Observer } from "./observer.ts"; /** * Checks if a {@linkcode value} is an object that implements the {@linkcode Observer} interface. * @example * ```ts - * import { isObserver, Observer } from "@xan/observable-core"; + * import { isObserver, Observer } from "@observable/core"; * * const instance = new Observer((value) => { * // Implementation omitted for brevity. diff --git a/core/is-subject.test.ts b/core/is-subject.test.ts index 93b0fde..5acefe5 100644 --- a/core/is-subject.test.ts +++ b/core/is-subject.test.ts @@ -1,7 +1,7 @@ import { assertEquals } from "@std/assert"; import { isSubject } from "./is-subject.ts"; import { Subject } from "./subject.ts"; -import { noop } from "@xan/observable-internal"; +import { noop } from "@observable/internal"; Deno.test( "isSubject should return true if the value is an instance of Subject", diff --git a/core/is-subject.ts b/core/is-subject.ts index c03d7e2..c5e748a 100644 --- a/core/is-subject.ts +++ b/core/is-subject.ts @@ -1,4 +1,4 @@ -import { MinimumArgumentsRequiredError } from "@xan/observable-internal"; +import { MinimumArgumentsRequiredError } from "@observable/internal"; import { Subject } from "./subject.ts"; import { isObservable } from "./is-observable.ts"; import { isObserver } from "./is-observer.ts"; @@ -7,7 +7,7 @@ import { isObserver } from "./is-observer.ts"; * Checks if a {@linkcode value} is an object that implements the {@linkcode Subject} interface. * @example * ```ts - * import { isSubject, Subject } from "@xan/observable-core"; + * import { isSubject, Subject } from "@observable/core"; * * const subjectInstance = new Subject(); * isSubject(subjectInstance); // true diff --git a/core/observable-constructor.ts b/core/observable-constructor.ts index 9c23558..1b0d03b 100644 --- a/core/observable-constructor.ts +++ b/core/observable-constructor.ts @@ -6,13 +6,13 @@ import type { Observer } from "./observer.ts"; */ export interface ObservableConstructor { /** - * Creates and returns an object that acts as a template for connecting a [producer](https://jsr.io/@xan/observable-core#producer) - * to a [consumer](https://jsr.io/@xan/observable-core#consumer) via a {@linkcode Observable.subscribe|subscribe} action. + * Creates and returns an object that acts as a template for connecting a [producer](https://jsr.io/@observable/core#producer) + * to a [consumer](https://jsr.io/@observable/core#consumer) via a {@linkcode Observable.subscribe|subscribe} action. * @param subscribe The function called for each {@linkcode Observable.subscribe|subscribe} action. * @example * Creating an observable with a synchronous producer. * ```ts - * import { Observable } from "@xan/observable-core"; + * import { Observable } from "@observable/core"; * * const observable = new Observable((observer) => { * // Create an Array as our producer to next a sequence of values. @@ -37,7 +37,7 @@ export interface ObservableConstructor { * throw: (value) => console.error("throw", value), * }); * - * // console output (synchronously): + * // Console output (synchronously): * // "next" 1 * // "next" 2 * // "next" 3 @@ -47,7 +47,7 @@ export interface ObservableConstructor { * @example * Creating an observable with an asynchronous producer. * ```ts - * import { Observable } from "@xan/observable-core"; + * import { Observable } from "@observable/core"; * * const observable = new Observable<0>((observer) => { * // Create a timeout as our producer to next a successful execution code (0) after 1 second. @@ -76,7 +76,7 @@ export interface ObservableConstructor { * throw: (value) => console.error("throw", value), * }); * - * // console output (asynchronously): + * // Console output (asynchronously): * // "next" 0 * // "return" * ``` @@ -84,7 +84,7 @@ export interface ObservableConstructor { * @example * Creating an observable with no producer. * ```ts - * import { Observable } from "@xan/observable-core"; + * import { Observable } from "@observable/core"; * * const observable = new Observable(); * diff --git a/core/observable.test.ts b/core/observable.test.ts index 00737aa..58bcf53 100644 --- a/core/observable.test.ts +++ b/core/observable.test.ts @@ -1,7 +1,7 @@ import { assertEquals, assertInstanceOf, assertStrictEquals, assertThrows } from "@std/assert"; import { Observer } from "./observer.ts"; import { Observable } from "./observable.ts"; -import { noop } from "@xan/observable-internal"; +import { noop } from "@observable/internal"; Deno.test("Observable.toString should be '[object Observable]'", () => { // Arrange / Act / Assert diff --git a/core/observable.ts b/core/observable.ts index 71105bd..8a83091 100644 --- a/core/observable.ts +++ b/core/observable.ts @@ -2,7 +2,7 @@ import { InstanceofError, MinimumArgumentsRequiredError, ParameterTypeError, -} from "@xan/observable-internal"; +} from "@observable/internal"; import { isObserver } from "./is-observer.ts"; import type { Observer } from "./observer.ts"; import { toObserver } from "./to-observer.ts"; @@ -10,14 +10,14 @@ import type { ObservableConstructor } from "./observable-constructor.ts"; /** * Object interface that acts as a template for connecting an {@linkcode Observer}, as a - * [consumer](https://jsr.io/@xan/observable-core#consumer), to a [producer](https://jsr.io/@xan/observable-core#producer), + * [consumer](https://jsr.io/@observable/core#consumer), to a [producer](https://jsr.io/@observable/core#producer), * via a {@linkcode Observable.subscribe|subscribe} action. */ export interface Observable { /** - * The act of a [consumer](https://jsr.io/@xan/observable-core#consumer) requesting from an - * {@linkcode Observable} to set up a [`subscription`](https://jsr.io/@xan/observable-core#subscription) - * so that it may [`observe`](https://jsr.io/@xan/observable-core#observation) a [producer](https://jsr.io/@xan/observable-core#producer). + * The act of a [consumer](https://jsr.io/@observable/core#consumer) requesting from an + * {@linkcode Observable} to set up a [`subscription`](https://jsr.io/@observable/core#subscription) + * so that it may [`observe`](https://jsr.io/@observable/core#observation) a [producer](https://jsr.io/@observable/core#producer). */ subscribe(observer: Observer): void; } diff --git a/core/observer-constructor.ts b/core/observer-constructor.ts index acf43de..67cea04 100644 --- a/core/observer-constructor.ts +++ b/core/observer-constructor.ts @@ -5,10 +5,10 @@ import type { Observer } from "./observer.ts"; */ export interface ObserverConstructor { /** - * Creates and return a object that provides a standard way to [`consume`](https://jsr.io/@xan/observable-core#consumer) a sequence of values + * Creates and return a object that provides a standard way to [`consume`](https://jsr.io/@observable/core#consumer) a sequence of values * (either finite or infinite). * ```ts - * import { Observer } from "@xan/observable-core"; + * import { Observer } from "@observable/core"; * * const observer = new Observer<0>({ * next: (value) => console.log(value), diff --git a/core/observer.test.ts b/core/observer.test.ts index 04c8aa6..56259fc 100644 --- a/core/observer.test.ts +++ b/core/observer.test.ts @@ -1,4 +1,4 @@ -import { noop } from "@xan/observable-internal"; +import { noop } from "@observable/internal"; import { Observer } from "./observer.ts"; import { assertEquals, assertInstanceOf, assertStrictEquals, assertThrows } from "@std/assert"; import { isObserver } from "./is-observer.ts"; diff --git a/core/observer.ts b/core/observer.ts index 08c7d78..430b7f7 100644 --- a/core/observer.ts +++ b/core/observer.ts @@ -5,11 +5,11 @@ import { isObject, MinimumArgumentsRequiredError, ParameterTypeError, -} from "@xan/observable-internal"; +} from "@observable/internal"; import type { ObserverConstructor } from "./observer-constructor.ts"; /** - * Object interface that defines a standard way to [`consume`](https://jsr.io/@xan/observable-core#consumer) a + * Object interface that defines a standard way to [`consume`](https://jsr.io/@observable/core#consumer) a * sequence of values (either finite or infinite). */ // This is meant to reflect similar semantics as the Iterator protocol, while also supporting aborts. @@ -17,21 +17,21 @@ import type { ObserverConstructor } from "./observer-constructor.ts"; // is different. export interface Observer { /** - * The [consumer](https://jsr.io/@xan/observable-core#consumer) is telling the [producer](https://jsr.io/@xan/observable-core#producer) + * The [consumer](https://jsr.io/@observable/core#consumer) is telling the [producer](https://jsr.io/@observable/core#producer) * it's no longer interested in receiving {@linkcode Value|values}. */ readonly signal: AbortSignal; /** - * The [producer](https://jsr.io/@xan/observable-core#producer) is pushing a {@linkcode value} to the [consumer](https://jsr.io/@xan/observable-core#consumer). + * The [producer](https://jsr.io/@observable/core#producer) is pushing a {@linkcode value} to the [consumer](https://jsr.io/@observable/core#consumer). */ next(value: Value): void; /** - * The [producer](https://jsr.io/@xan/observable-core#producer) is telling the [consumer](https://jsr.io/@xan/observable-core#consumer) + * The [producer](https://jsr.io/@observable/core#producer) is telling the [consumer](https://jsr.io/@observable/core#consumer) * that it does not intend to {@linkcode next} any more values, and can perform any cleanup actions. */ return(): void; /** - * The [producer](https://jsr.io/@xan/observable-core#producer) is telling the [consumer](https://jsr.io/@xan/observable-core#consumer) that + * The [producer](https://jsr.io/@observable/core#producer) is telling the [consumer](https://jsr.io/@observable/core#consumer) that * it has encountered a {@linkcode value|problem}, does not intend to {@linkcode next} any more values, and can perform any cleanup actions. */ throw(value: unknown): void; diff --git a/core/subject-constructor.ts b/core/subject-constructor.ts index e9bd505..39810a1 100644 --- a/core/subject-constructor.ts +++ b/core/subject-constructor.ts @@ -5,15 +5,15 @@ import type { Subject } from "./subject.ts"; */ export interface SubjectConstructor { /** - * Creates and returns an object that acts as both an [`observer`](https://jsr.io/@xan/observable-core/doc/~/Observer) - * ([`multicast`](https://jsr.io/@xan/observable-core#multicast)) and an [`observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) - * ([`hot`](https://jsr.io/@xan/observable-core#hot)). [`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return) - * and [`throw`](https://jsr.io/@xan/observable-core/doc/~/Observer.throw) will be replayed to late - * [`consumers`](https://jsr.io/@xan/observable-core#consumer) upon [`subscription`](https://jsr.io/@xan/observable-core/doc/~/Observable.subscribe). + * Creates and returns an object that acts as both an [`observer`](https://jsr.io/@observable/core/doc/~/Observer) + * ([`multicast`](https://jsr.io/@observable/core#multicast)) and an [`observable`](https://jsr.io/@observable/core/doc/~/Observable) + * ([`hot`](https://jsr.io/@observable/core#hot)). [`return`](https://jsr.io/@observable/core/doc/~/Observer.return) + * and [`throw`](https://jsr.io/@observable/core/doc/~/Observer.throw) will be replayed to late + * [`consumers`](https://jsr.io/@observable/core#consumer) upon [`subscription`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). * @example * Basic * ```ts - * import { Subject } from "@xan/observable-core"; + * import { Subject } from "@observable/core"; * * const subject = new Subject(); * const controller = new AbortController(); @@ -27,7 +27,7 @@ export interface SubjectConstructor { * * subject.next(1); * - * // console output: + * // Console output: * // 1 * * subject.subscribe({ @@ -39,13 +39,13 @@ export interface SubjectConstructor { * * subject.next(2); * - * // console output: + * // Console output: * // 2 * // 2 * * subject.return(); * - * // console output: + * // Console output: * // return * * subject.subscribe({ @@ -55,13 +55,13 @@ export interface SubjectConstructor { * throw: () => console.error("throw"), * }); * - * // console output: + * // Console output: * // return * ``` * @example * Advanced * ```ts - * import { Subject, toObservable } from "@xan/observable-core"; + * import { Subject, toObservable } from "@observable/core"; * * class Authenticator { * readonly #events = new Subject(); diff --git a/core/subject.ts b/core/subject.ts index e874a5c..3d1e4ec 100644 --- a/core/subject.ts +++ b/core/subject.ts @@ -5,7 +5,7 @@ import { InstanceofError, MinimumArgumentsRequiredError, ParameterTypeError, -} from "@xan/observable-internal"; +} from "@observable/internal"; import type { SubjectConstructor } from "./subject-constructor.ts"; /** @@ -22,7 +22,7 @@ const notThrown = Symbol("Flag indicating that a value is not thrown."); export const Subject: SubjectConstructor = class { readonly [Symbol.toStringTag] = "Subject"; /** - * Tracking the value that was thrown by the [producer](https://jsr.io/@xan/observable-core#producer), if any. + * Tracking the value that was thrown by the [producer](https://jsr.io/@observable/core#producer), if any. */ #thrown: unknown = notThrown; diff --git a/core/to-observable.test.ts b/core/to-observable.test.ts index 3158d3b..81d7d94 100644 --- a/core/to-observable.test.ts +++ b/core/to-observable.test.ts @@ -1,4 +1,4 @@ -import { Observable, Observer } from "@xan/observable-core"; +import { Observable, Observer } from "@observable/core"; import { assertEquals, assertInstanceOf, assertStrictEquals } from "@std/assert"; import { toObservable } from "./to-observable.ts"; diff --git a/core/to-observable.ts b/core/to-observable.ts index f8f9343..d5af9b2 100644 --- a/core/to-observable.ts +++ b/core/to-observable.ts @@ -1,4 +1,4 @@ -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; import { isObservable } from "./is-observable.ts"; import { Observable } from "./observable.ts"; @@ -9,7 +9,7 @@ import { Observable } from "./observable.ts"; * that wraps the original {@linkcode value}. * @example * ```ts - * import { toObservable, Observable } from "@xan/observable-core"; + * import { toObservable, Observable } from "@observable/core"; * * const observableInstance = new Observable((observer) => { * // Implementation omitted for brevity. @@ -21,7 +21,7 @@ import { Observable } from "./observable.ts"; * ``` * @example * ```ts - * import { toObservable, Observable } from "@xan/observable-core"; + * import { toObservable, Observable } from "@observable/core"; * * const customObservable: Observable = { * subscribe(observer) { diff --git a/core/to-observer.test.ts b/core/to-observer.test.ts index 6d5d7a4..8012db9 100644 --- a/core/to-observer.test.ts +++ b/core/to-observer.test.ts @@ -1,4 +1,4 @@ -import { Observer } from "@xan/observable-core"; +import { Observer } from "@observable/core"; import { assertEquals, assertInstanceOf, assertStrictEquals } from "@std/assert"; import { toObserver } from "./to-observer.ts"; diff --git a/core/to-observer.ts b/core/to-observer.ts index 2b404b7..e279721 100644 --- a/core/to-observer.ts +++ b/core/to-observer.ts @@ -1,4 +1,4 @@ -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; import { Observer } from "./observer.ts"; import { isObserver } from "./is-observer.ts"; @@ -9,7 +9,7 @@ import { isObserver } from "./is-observer.ts"; * that wraps the original {@linkcode value}. * @example * ```ts - * import { toObserver, Observer } from "@xan/observable-core"; + * import { toObserver, Observer } from "@observable/core"; * * const instance = new Observer((value) => { * // Implementation omitted for brevity. @@ -21,7 +21,7 @@ import { isObserver } from "./is-observer.ts"; * ``` * @example * ```ts - * import { toObserver, Observer } from "@xan/observable-core"; + * import { toObserver, Observer } from "@observable/core"; * * const custom: Observer = { * signal: new AbortController().signal, diff --git a/debounce/README.md b/debounce/README.md new file mode 100644 index 0000000..ed36eb7 --- /dev/null +++ b/debounce/README.md @@ -0,0 +1,47 @@ +# @observable/debounce + +Debounces the emission of values from the [source](https://jsr.io/@observable/core#source) +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) by the specified number of +milliseconds. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { debounce } from "@observable/debounce"; +import { Subject } from "@observable/core"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +const source = new Subject(); + +pipe(source, debounce(100)).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +source.next(1); +source.next(2); +source.next(3); // Only this value will be emitted after 100ms + +// Console output (after 100ms): +// "next" 3 +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/debounce/deno.json b/debounce/deno.json new file mode 100644 index 0000000..96bbebb --- /dev/null +++ b/debounce/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/debounce", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/debounce/mod.test.ts b/debounce/mod.test.ts new file mode 100644 index 0000000..918875b --- /dev/null +++ b/debounce/mod.test.ts @@ -0,0 +1,246 @@ +import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; +import { Observable, Observer, Subject } from "@observable/core"; +import { empty } from "@observable/empty"; +import { pipe } from "@observable/pipe"; +import { of } from "@observable/of"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { debounce } from "./mod.ts"; + +Deno.test("debounce should return empty if milliseconds is negative", () => { + // Arrange + const source = of([1, 2, 3]); + + // Act + const result = pipe(source, debounce(-1)); + + // Assert + assertStrictEquals(result, empty); +}); + +Deno.test("debounce should return empty if milliseconds is NaN", () => { + // Arrange + const source = of([1, 2, 3]); + + // Act + const result = pipe(source, debounce(NaN)); + + // Assert + assertStrictEquals(result, empty); +}); + +Deno.test("debounce should return empty if milliseconds is Infinity", () => { + // Arrange + const source = of([1, 2, 3]); + + // Act + const result = pipe(source, debounce(Infinity)); + + // Assert + assertStrictEquals(result, empty); +}); + +Deno.test("debounce should emit value after timer expires", () => { + // Arrange + let overrideGlobals = true; + const notifications: Array> = []; + const setTimeoutCalls: Array> = []; + const originalSetTimeout = globalThis.setTimeout; + Object.defineProperty(globalThis, "setTimeout", { + value: (...args: Parameters) => { + setTimeoutCalls.push(args); + return overrideGlobals ? Math.random() : originalSetTimeout(...args); + }, + }); + + const source = new Subject(); + const materialized = pipe(source, debounce(100), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + source.next(1); + assertEquals(notifications, []); + const [[callback]] = setTimeoutCalls; + (callback as () => void)(); + + // Assert + assertEquals(notifications, [["next", 1]]); + overrideGlobals = false; +}); + +Deno.test("debounce should only emit the latest value when multiple values are emitted rapidly", () => { + // Arrange + let overrideGlobals = true; + const notifications: Array> = []; + const setTimeoutCalls: Array> = []; + const clearTimeoutCalls: Array> = []; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + Object.defineProperty(globalThis, "setTimeout", { + value: (...args: Parameters) => { + setTimeoutCalls.push(args); + return overrideGlobals ? setTimeoutCalls.length : originalSetTimeout(...args); + }, + }); + Object.defineProperty(globalThis, "clearTimeout", { + value: (...args: Parameters) => { + clearTimeoutCalls.push(args); + return overrideGlobals ? undefined : originalClearTimeout(...args); + }, + }); + + const source = new Subject(); + const materialized = pipe(source, debounce(100), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + source.next(1); + source.next(2); + source.next(3); + const lastCallback = setTimeoutCalls[setTimeoutCalls.length - 1][0]; + (lastCallback as () => void)(); + + // Assert + assertEquals(notifications, [["next", 3]]); + overrideGlobals = false; +}); + +Deno.test("debounce should emit immediately if milliseconds is 0", () => { + // Arrange + const notifications: Array> = []; + const source = new Subject(); + const materialized = pipe(source, debounce(0), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + source.next(1); + source.next(2); + source.next(3); + source.return(); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", 3], + ["return"], + ]); +}); + +Deno.test("debounce should pump throws right through itself", () => { + // Arrange + let overrideGlobals = true; + const error = new Error("test error"); + const notifications: Array> = []; + const originalSetTimeout = globalThis.setTimeout; + Object.defineProperty(globalThis, "setTimeout", { + value: (...args: Parameters) => { + return overrideGlobals ? Math.random() : originalSetTimeout(...args); + }, + }); + + const source = new Observable((observer) => { + observer.next(1); + observer.throw(error); + }); + const materialized = pipe(source, debounce(100), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["throw", error]]); + + overrideGlobals = false; +}); + +Deno.test("debounce should honor unsubscribe", () => { + // Arrange + let overrideGlobals = true; + const controller = new AbortController(); + const notifications: Array> = []; + const setTimeoutCalls: Array> = []; + const clearTimeoutCalls: Array> = []; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + Object.defineProperty(globalThis, "setTimeout", { + value: (...args: Parameters) => { + setTimeoutCalls.push(args); + return overrideGlobals ? setTimeoutCalls.length : originalSetTimeout(...args); + }, + }); + Object.defineProperty(globalThis, "clearTimeout", { + value: (...args: Parameters) => { + clearTimeoutCalls.push(args); + return overrideGlobals ? undefined : originalClearTimeout(...args); + }, + }); + + const source = new Subject(); + const materialized = pipe(source, debounce(100), materialize()); + + // Act + materialized.subscribe( + new Observer({ + signal: controller.signal, + next: (notification) => notifications.push(notification), + }), + ); + source.next(1); + controller.abort(); + + // Assert + assertEquals(notifications, []); + + overrideGlobals = false; +}); + +Deno.test("debounce should throw when called with no arguments", () => { + // Arrange / Act / Assert + assertThrows( + () => debounce(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + +Deno.test("debounce should throw when milliseconds is not a number", () => { + // Arrange / Act / Assert + assertThrows( + () => debounce("s" as unknown as number), + TypeError, + "Parameter 1 is not of type 'Number'", + ); +}); + +Deno.test("debounce should throw when called with no source", () => { + // Arrange + const operator = debounce(100); + + // Act / Assert + assertThrows( + () => operator(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + +Deno.test("debounce should throw when source is not an Observable", () => { + // Arrange + const operator = debounce(100); + + // Act / Assert + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(1 as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); +}); diff --git a/debounce/mod.ts b/debounce/mod.ts new file mode 100644 index 0000000..8ae71d7 --- /dev/null +++ b/debounce/mod.ts @@ -0,0 +1,63 @@ +import { isObservable, type Observable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; +import { empty } from "@observable/empty"; +import { pipe } from "@observable/pipe"; +import { switchMap } from "@observable/switch-map"; +import { timer } from "@observable/timer"; +import { map } from "@observable/map"; + +/** + * Debounces the emission of values from the [source](https://jsr.io/@observable/core#source) + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) by the specified number of {@linkcode milliseconds}. + * @example + * ```ts + * import { debounce } from "@observable/debounce"; + * import { Subject } from "@observable/core"; + * import { pipe } from "@observable/pipe"; + * + * const controller = new AbortController(); + * const source = new Subject(); + * + * pipe(source, debounce(100)).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * source.next(1); + * source.next(2); + * source.next(3); // Only this value will be emitted after 100ms + * + * // Console output (after 100ms): + * // "next" 3 + * ``` + */ +export function debounce( + milliseconds: number, +): (source: Observable) => Observable { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (typeof milliseconds !== "number") { + throw new ParameterTypeError(0, "Number"); + } + return function debounceFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + if ( + milliseconds < 0 || + Number.isNaN(milliseconds) || + milliseconds === Infinity + ) { + return empty; + } + return pipe( + source, + switchMap((value) => + pipe( + timer(milliseconds), + map(() => value), + ) + ), + ); + }; +} diff --git a/defer/README.md b/defer/README.md new file mode 100644 index 0000000..cfe7c2a --- /dev/null +++ b/defer/README.md @@ -0,0 +1,62 @@ +# @observable/defer + +Creates an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that, on +[`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe), calls an +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) factory to get an +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) for each +[`Observer`](https://jsr.io/@observable/core/doc/~/Observer). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { defer } from "@observable/defer"; +import { of } from "@observable/of"; + +const controller = new AbortController(); +let values = [1, 2, 3]; +const observable = defer(() => of(values)); + +observable.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.error("throw", value), +}); + +// Console output: +// "next" 1 +// "next" 2 +// "next" 3 +// "return" + +values = [4, 5, 6]; +observable.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.error("throw", value), +}); + +// Console output: +// "next" 4 +// "next" 5 +// "next" 6 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/defer/deno.json b/defer/deno.json new file mode 100644 index 0000000..956b11b --- /dev/null +++ b/defer/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/defer", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/defer/mod.test.ts b/defer/mod.test.ts new file mode 100644 index 0000000..8213068 --- /dev/null +++ b/defer/mod.test.ts @@ -0,0 +1,59 @@ +import { assertEquals, assertStrictEquals } from "@std/assert"; +import { Observer } from "@observable/core"; +import { materialize } from "@observable/materialize"; +import type { ObserverNotification } from "@observable/materialize"; +import { pipe } from "@observable/pipe"; +import { of } from "@observable/of"; +import { defer } from "./mod.ts"; + +Deno.test( + "defer should create an Observable that calls a factory to make an Observable for each new Observer", + () => { + // Arrange + let factoryCallCount = 0; + const notifications: Array<[1 | 2, ObserverNotification]> = []; + const source = defer(() => { + factoryCallCount++; + return of([1, 2, 3]); + }); + + // Act + pipe(source, materialize()).subscribe( + new Observer((notification) => notifications.push([1, notification])), + ); + pipe(source, materialize()).subscribe( + new Observer((notification) => notifications.push([2, notification])), + ); + + // Assert + assertStrictEquals(factoryCallCount, 2); + assertEquals(notifications, [ + [1, ["next", 1]], + [1, ["next", 2]], + [1, ["next", 3]], + [1, ["return"]], + [2, ["next", 1]], + [2, ["next", 2]], + [2, ["next", 3]], + [2, ["return"]], + ]); + }, +); + +Deno.test("defer should throw an error if the factory throws an error", () => { + // Arrange + const error = new Error(Math.random().toString()); + const notifications: Array = []; + const source = defer(() => { + throw error; + }); + const materialized = pipe(source, materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["throw", error]]); +}); diff --git a/defer/mod.ts b/defer/mod.ts new file mode 100644 index 0000000..4c6a153 --- /dev/null +++ b/defer/mod.ts @@ -0,0 +1,54 @@ +import { Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; + +/** + * Creates an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that, on + * [`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe), calls an + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) {@linkcode factory} to + * get an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) for each + * [`Observer`](https://jsr.io/@observable/core/doc/~/Observer). + * @example + * ```ts + * import { defer } from "@observable/defer"; + * import { of } from "@observable/of"; + * + * const controller = new AbortController(); + * let values = [1, 2, 3]; + * const observable = defer(() => of(values)); + * + * observable.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.error("throw", value), + * }); + * + * // Console output: + * // "next" 1 + * // "next" 2 + * // "next" 3 + * // "return" + * + * values = [4, 5, 6]; + * observable.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.error("throw", value), + * }); + * + * // Console output: + * // "next" 4 + * // "next" 5 + * // "next" 6 + * // "return" + */ +export function defer( + factory: () => Observable, +): Observable { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (typeof factory !== "function") { + throw new ParameterTypeError(0, "Function"); + } + return new Observable((observer) => toObservable(factory()).subscribe(observer)); +} diff --git a/deno.json b/deno.json index b45506e..6444d9a 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,47 @@ { - "workspace": ["core", "common", "internal", "web"], + "workspace": [ + "all", + "as-async-iterable", + "as-promise", + "async-subject", + "behavior-subject", + "broadcast-subject", + "catch-error", + "core", + "debounce", + "defer", + "distinct", + "distinct-until-changed", + "drop", + "empty", + "exhaust-map", + "filter", + "finalize", + "flat", + "flat-map", + "ignore-elements", + "internal", + "interval", + "keep-alive", + "map", + "materialize", + "merge", + "merge-map", + "never", + "of", + "pairwise", + "pipe", + "race", + "replay-subject", + "share", + "switch-map", + "take", + "take-until", + "tap", + "throttle", + "throw-error", + "timer" + ], "license": "MIT", "tasks": { "test": "deno test --watch", diff --git a/distinct-until-changed/README.md b/distinct-until-changed/README.md new file mode 100644 index 0000000..853843f --- /dev/null +++ b/distinct-until-changed/README.md @@ -0,0 +1,44 @@ +# @observable/distinct-until-changed + +Only [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)s values from the +[source](https://jsr.io/@observable/core#source) that are distinct from the previous value according +to a specified comparator. Defaults to comparing with `Object.is`. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { distinctUntilChanged } from "@observable/distinct-until-changed"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe(of([1, 1, 1, 2, 2, 3]), distinctUntilChanged()).subscribe({ + signal: controller.signal, + next: (value) => console.log(value), + return: () => console.log("return"), + throw: (value) => console.log(value), +}); + +// Console output: +// 1 +// 2 +// 3 +// return +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/distinct-until-changed/deno.json b/distinct-until-changed/deno.json new file mode 100644 index 0000000..57889dc --- /dev/null +++ b/distinct-until-changed/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/distinct-until-changed", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/distinct-until-changed/mod.test.ts b/distinct-until-changed/mod.test.ts new file mode 100644 index 0000000..289acd2 --- /dev/null +++ b/distinct-until-changed/mod.test.ts @@ -0,0 +1,260 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { Observable, Observer, Subject } from "@observable/core"; +import { pipe } from "@observable/pipe"; +import { of } from "@observable/of"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { distinctUntilChanged } from "./mod.ts"; +import { flat } from "@observable/flat"; +import { throwError } from "@observable/throw-error"; + +Deno.test( + "distinctUntilChanged should filter out consecutive duplicate values", + () => { + // Arrange + const notifications: Array> = []; + const source = of([1, 1, 1, 2, 2, 3, 3, 3, 1, 1]); + const materialized = pipe(source, distinctUntilChanged(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", 3], + ["next", 1], + ["return"], + ]); + }, +); + +Deno.test( + "distinctUntilChanged should emit all values when none are consecutive duplicates", + () => { + // Arrange + const notifications: Array> = []; + const source = of([1, 2, 3, 4, 5]); + const materialized = pipe(source, distinctUntilChanged(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", 3], + ["next", 4], + ["next", 5], + ["return"], + ]); + }, +); + +Deno.test( + "distinctUntilChanged should emit only first value when all values are the same", + () => { + // Arrange + const notifications: Array> = []; + const source = of([5, 5, 5, 5, 5]); + const materialized = pipe(source, distinctUntilChanged(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["next", 5], ["return"]]); + }, +); + +Deno.test("distinctUntilChanged should handle empty source", () => { + // Arrange + const notifications: Array> = []; + const source = of([]); + const materialized = pipe(source, distinctUntilChanged(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["return"]]); +}); + +Deno.test("distinctUntilChanged should pump throws right through itself", () => { + // Arrange + const error = new Error("test error"); + const notifications: Array> = []; + const source = flat([of([1, 1, 2]), throwError(error)]); + const materialized = pipe(source, distinctUntilChanged(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["throw", error], + ]); +}); + +Deno.test("distinctUntilChanged should honor unsubscribe", () => { + // Arrange + const controller = new AbortController(); + const notifications: Array> = []; + const source = flat([of([1, 2, 2, 3, 3, 3]), throwError(new Error("Should not make it here"))]); + const materialized = pipe(source, distinctUntilChanged(), materialize()); + + // Act + materialized.subscribe( + new Observer({ + signal: controller.signal, + next: (notification) => { + notifications.push(notification); + if (notification[0] === "next" && notification[1] === 2) { + controller.abort(); + } + }, + }), + ); + + // Assert + assertEquals(notifications, [["next", 1], ["next", 2]]); +}); + +Deno.test( + "distinctUntilChanged should use custom comparator when provided", + () => { + // Arrange + const notifications: Array> = []; + const source = of([ + { id: 1 }, + { id: 1 }, + { id: 2 }, + { id: 2 }, + { id: 3 }, + ]); + const comparator = (a: { id: number }, b: { id: number }) => a.id === b.id; + const materialized = pipe( + source, + distinctUntilChanged(comparator), + materialize(), + ); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", { id: 1 }], + ["next", { id: 2 }], + ["next", { id: 3 }], + ["return"], + ]); + }, +); + +Deno.test( + "distinctUntilChanged should use Object.is by default", + () => { + // Arrange + const notifications: Array> = []; + const source = of([NaN, NaN, 1, 1]); + const materialized = pipe(source, distinctUntilChanged(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", NaN], + ["next", 1], + ["return"], + ]); + }, +); + +Deno.test( + "distinctUntilChanged should throw when comparator is not a function", + () => { + // Arrange / Act / Assert + assertThrows( + // deno-lint-ignore no-explicit-any + () => distinctUntilChanged(1 as any), + TypeError, + "Parameter 1 is not of type 'Function'", + ); + }, +); + +Deno.test( + "distinctUntilChanged should throw when called with no source", + () => { + // Arrange + const operator = distinctUntilChanged(); + + // Act / Assert + assertThrows( + () => operator(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); + }, +); + +Deno.test( + "distinctUntilChanged should throw when source is not an Observable", + () => { + // Arrange + const operator = distinctUntilChanged(); + + // Act / Assert + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(1 as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); + }, +); + +Deno.test("distinctUntilChanged should work with Subject", () => { + // Arrange + const notifications: Array> = []; + const source = new Subject(); + const materialized = pipe(source, distinctUntilChanged(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + source.next(1); + source.next(1); + source.next(2); + source.next(2); + source.next(1); + source.return(); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", 1], + ["return"], + ]); +}); diff --git a/distinct-until-changed/mod.ts b/distinct-until-changed/mod.ts new file mode 100644 index 0000000..3867ee2 --- /dev/null +++ b/distinct-until-changed/mod.ts @@ -0,0 +1,67 @@ +import { isObservable, type Observable, Observer, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@observable/internal"; +import { defer } from "@observable/defer"; +import { pipe } from "@observable/pipe"; +import { tap } from "@observable/tap"; +import { filter } from "@observable/filter"; + +/** + * Flag indicating that no value has been emitted yet. + * @internal Do NOT export. + */ +const noValue = Symbol("Flag indicating that no value has been emitted yet"); + +/** + * Only [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)s values from the [source](https://jsr.io/@observable/core#source) + * that are distinct from the previous value according to a specified {@linkcode comparator}. Defaults to comparing with `Object.is`. + * @example + * ```ts + * import { distinctUntilChanged } from "@observable/distinct-until-changed"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * + * const controller = new AbortController(); + * pipe(of([1, 1, 1, 2, 2, 3]), distinctUntilChanged()).subscribe({ + * signal: controller.signal, + * next: (value) => console.log(value), + * return: () => console.log("return"), + * throw: (value) => console.log(value), + * }); + * + * // Console output: + * // 1 + * // 2 + * // 3 + * // return + * ``` + */ +export function distinctUntilChanged( + // Default to Object.is because it's behavior is more predictable than + // strict equality checks. + comparator: (previous: Value, current: Value) => boolean = Object.is, +): (source: Observable) => Observable { + if (typeof comparator !== "function") { + throw new ParameterTypeError(0, "Function"); + } + return function distinctUntilChangedFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + source = toObservable(source); + return defer(() => { + let previous: Value | typeof noValue = noValue; + return pipe( + source, + filter(isDistinct), + tap(new Observer({ next: processNextValue, throw: noop })), + ); + + function isDistinct(current: Value): boolean { + return previous === noValue || !comparator(previous, current); + } + + function processNextValue(current: Value): void { + previous = current; + } + }); + }; +} diff --git a/distinct/README.md b/distinct/README.md new file mode 100644 index 0000000..685e54d --- /dev/null +++ b/distinct/README.md @@ -0,0 +1,45 @@ +# @observable/distinct + +Only [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)s values from the +[source](https://jsr.io/@observable/core#source) +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that are distinct from all previous +values. Defaults to comparing with `Object.is`. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { distinct } from "@observable/distinct"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe(of([1, 2, 2, 3, 1, 3]), distinct()).subscribe({ + signal: controller.signal, + next: (value) => console.log(value), + return: () => console.log("return"), + throw: (value) => console.log(value), +}); + +// Console output: +// 1 +// 2 +// 3 +// return +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/distinct/deno.json b/distinct/deno.json new file mode 100644 index 0000000..37ed7d7 --- /dev/null +++ b/distinct/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/distinct", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/distinct/mod.test.ts b/distinct/mod.test.ts new file mode 100644 index 0000000..39d0619 --- /dev/null +++ b/distinct/mod.test.ts @@ -0,0 +1,262 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { Observable, Observer, Subject } from "@observable/core"; +import { pipe } from "@observable/pipe"; +import { of } from "@observable/of"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { distinct } from "./mod.ts"; +import { flat } from "@observable/flat"; +import { throwError } from "@observable/throw-error"; + +Deno.test( + "distinct should filter out all duplicate values across the stream", + () => { + // Arrange + const notifications: Array> = []; + const source = of([1, 2, 2, 3, 1, 3, 4, 2]); + const materialized = pipe(source, distinct(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", 3], + ["next", 4], + ["return"], + ]); + }, +); + +Deno.test("distinct should emit all values when none are duplicates", () => { + // Arrange + const notifications: Array> = []; + const source = of([1, 2, 3, 4, 5]); + const materialized = pipe(source, distinct(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", 3], + ["next", 4], + ["next", 5], + ["return"], + ]); +}); + +Deno.test( + "distinct should emit only first value when all values are the same", + () => { + // Arrange + const notifications: Array> = []; + const source = of([5, 5, 5, 5, 5]); + const materialized = pipe(source, distinct(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["next", 5], ["return"]]); + }, +); + +Deno.test("distinct should handle empty source", () => { + // Arrange + const notifications: Array> = []; + const source = of([]); + const materialized = pipe(source, distinct(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["return"]]); +}); + +Deno.test("distinct should pump throws right through itself", () => { + // Arrange + const error = new Error("test error"); + const notifications: Array> = []; + const source = new Observable((observer) => { + observer.next(1); + observer.next(2); + observer.next(1); + observer.throw(error); + }); + const materialized = pipe(source, distinct(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["throw", error], + ]); +}); + +Deno.test("distinct should honor unsubscribe", () => { + // Arrange + const controller = new AbortController(); + const notifications: Array> = []; + const source = flat([of([1, 2, 3, 1, 2, 3]), throwError(new Error("Should not make it here"))]); + const materialized = pipe(source, distinct(), materialize()); + + // Act + materialized.subscribe( + new Observer({ + signal: controller.signal, + next: (notification) => { + notifications.push(notification); + if (notification[0] === "next" && notification[1] === 2) { + controller.abort(); + } + }, + }), + ); + + // Assert + assertEquals(notifications, [["next", 1], ["next", 2]]); +}); + +Deno.test("distinct should throw when called with no source", () => { + // Arrange + const operator = distinct(); + + // Act / Assert + assertThrows( + () => operator(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + +Deno.test("distinct should throw when source is not an Observable", () => { + // Arrange + const operator = distinct(); + + // Act / Assert + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(1 as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); +}); + +Deno.test("distinct should work with Subject", () => { + // Arrange + const notifications: Array> = []; + const source = new Subject(); + const materialized = pipe(source, distinct(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + source.next(1); + source.next(2); + source.next(1); + source.next(3); + source.next(2); + source.next(3); + source.return(); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", 3], + ["return"], + ]); +}); + +Deno.test("distinct should work with string values", () => { + // Arrange + const notifications: Array> = []; + const source = of(["a", "b", "a", "c", "b", "d"]); + const materialized = pipe(source, distinct(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", "a"], + ["next", "b"], + ["next", "c"], + ["next", "d"], + ["return"], + ]); +}); + +Deno.test("distinct should use reference equality for objects", () => { + // Arrange + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + const obj3 = { id: 1 }; + const notifications: Array> = []; + const source = of([obj1, obj2, obj1, obj3, obj2]); + const materialized = pipe(source, distinct(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", obj1], + ["next", obj2], + ["next", obj3], + ["return"], + ]); +}); + +Deno.test("distinct should reset state for each subscription", () => { + // Arrange + const notifications1: Array> = []; + const notifications2: Array> = []; + const source = of([1, 2, 1, 2, 3]); + const distinctSource = pipe(source, distinct()); + + // Act + pipe(distinctSource, materialize()).subscribe( + new Observer((notification) => notifications1.push(notification)), + ); + pipe(distinctSource, materialize()).subscribe( + new Observer((notification) => notifications2.push(notification)), + ); + + // Assert + assertEquals(notifications1, [ + ["next", 1], + ["next", 2], + ["next", 3], + ["return"], + ]); + assertEquals(notifications2, [ + ["next", 1], + ["next", 2], + ["next", 3], + ["return"], + ]); +}); diff --git a/distinct/mod.ts b/distinct/mod.ts new file mode 100644 index 0000000..8615a52 --- /dev/null +++ b/distinct/mod.ts @@ -0,0 +1,84 @@ +import { isObservable, type Observable, Observer, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@observable/internal"; +import { defer } from "@observable/defer"; +import { pipe } from "@observable/pipe"; +import { tap } from "@observable/tap"; +import { filter } from "@observable/filter"; + +/** + * Only [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)s values from the [source](https://jsr.io/@observable/core#source) + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that are distinct from the previous + * value according to a specified {@linkcode comparator}. Defaults to comparing with `Object.is`. + * @example + * ```ts + * import { distinct } from "@observable/distinct"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * + * const controller = new AbortController(); + * pipe(of([1, 2, 2, 3, 1, 3]), distinct()).subscribe({ + * signal: controller.signal, + * next: (value) => console.log(value), + * return: () => console.log("return"), + * throw: (value) => console.log(value), + * }); + * + * // Console output: + * // 1 + * // 2 + * // 3 + * // return + * ``` + * @example + * ```ts + * import { distinct } from "@observable/distinct"; + * import { Observer } from "@observable/core"; + * import { pipe } from "@observable/pipe"; + * import { defer } from "@observable/defer"; + * import { noop } from "@observable/internal"; + * import { of } from "@observable/of"; + * import { filter } from "@observable/filter"; + * import { tap } from "@observable/tap"; + * + * const controller = new AbortController(); + * const source = of([{ id: 1 }, { id: 2 }, { id: 2 }, { id: 3 }, { id: 1 }, { id: 3 }]); + * const observable = defer(() => { + * const values = new Set(); + * return pipe( + * source, + * filter((value) => !values.has(value.id)), + * tap(new Observer({ next: (value) => values.add(value.id), throw: noop })), + * ); + * }); + * + * observable.subscribe({ + * signal: controller.signal, + * next: (value) => console.log(value), + * return: () => console.log("return"), + * throw: (value) => console.log(value), + * }); + * + * // Console output: + * // { id: 1 } + * // { id: 2 } + * // { id: 3 } + * // return + * ``` + */ +export function distinct(): ( + source: Observable, +) => Observable { + return function distinctFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + source = toObservable(source); + return defer(() => { + const values = new Set(); + return pipe( + source, + filter((value) => !values.has(value)), + tap(new Observer({ next: (value) => values.add(value), throw: noop })), + ); + }); + }; +} diff --git a/drop/README.md b/drop/README.md new file mode 100644 index 0000000..dd53bb0 --- /dev/null +++ b/drop/README.md @@ -0,0 +1,43 @@ +# @observable/drop + +Drops the first `count` values [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed by +the [source](https://jsr.io/@observable/core#source). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { drop } from "@observable/drop"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe(of([1, 2, 3, 4, 5]), drop(2)).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 3 +// "next" 4 +// "next" 5 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/drop/deno.json b/drop/deno.json new file mode 100644 index 0000000..f8d0f1c --- /dev/null +++ b/drop/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/drop", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/drop.test.ts b/drop/mod.test.ts similarity index 83% rename from common/drop.test.ts rename to drop/mod.test.ts index 854cd58..4d2cfe8 100644 --- a/common/drop.test.ts +++ b/drop/mod.test.ts @@ -1,11 +1,10 @@ import { assertEquals, assertStrictEquals } from "@std/assert"; -import { Observer } from "@xan/observable-core"; -import { empty } from "./empty.ts"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { drop } from "./drop.ts"; +import { Observer } from "@observable/core"; +import { empty } from "@observable/empty"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { drop } from "./mod.ts"; Deno.test( "drop should return an empty observable if the count is less than 0", diff --git a/common/drop.ts b/drop/mod.ts similarity index 55% rename from common/drop.ts rename to drop/mod.ts index e6974f8..2e87dae 100644 --- a/common/drop.ts +++ b/drop/mod.ts @@ -1,30 +1,31 @@ -import { isObservable, type Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import { empty } from "./empty.ts"; -import { pipe } from "./pipe.ts"; -import { filter } from "./filter.ts"; -import { asObservable } from "./as-observable.ts"; +import { isObservable, type Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; +import { empty } from "@observable/empty"; +import { pipe } from "@observable/pipe"; +import { filter } from "@observable/filter"; /** - * Drops the first {@linkcode count} values [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed - * by the [source](https://jsr.io/@xan/observable-core#source). + * Drops the first {@linkcode count} values [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed + * by the [source](https://jsr.io/@observable/core#source). * @example * ```ts - * import { drop, of, pipe } from "@xan/observable-common"; + * import { drop } from "@observable/drop"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; * * const controller = new AbortController(); * pipe(of([1, 2, 3, 4, 5]), drop(2)).subscribe({ * signal: controller.signal, - * next: (value) => console.log(value), + * next: (value) => console.log("next", value), * return: () => console.log("return"), - * throw: (value) => console.log(value), + * throw: (value) => console.log("throw", value), * }); * - * // console output: - * // 3 - * // 4 - * // 5 - * // return + * // Console output: + * // "next" 3 + * // "next" 4 + * // "next" 5 + * // "return" * ``` */ export function drop( @@ -38,7 +39,7 @@ export function drop( if (count < 0 || Number.isNaN(count) || count === Infinity) return empty; return pipe( source, - count === 0 ? asObservable() : filter((_, index) => index >= count), + count === 0 ? toObservable : filter((_, index) => index >= count), ); }; } diff --git a/empty/README.md b/empty/README.md new file mode 100644 index 0000000..60c86e6 --- /dev/null +++ b/empty/README.md @@ -0,0 +1,40 @@ +# @observable/empty + +An [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that calls +[`return`](https://jsr.io/@observable/core/doc/~/Observer.return) immediately on +[`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { empty } from "@observable/empty"; + +const controller = new AbortController(); + +empty.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/empty/deno.json b/empty/deno.json new file mode 100644 index 0000000..e6dc0e2 --- /dev/null +++ b/empty/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/empty", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/empty.test.ts b/empty/mod.test.ts similarity index 84% rename from common/empty.test.ts rename to empty/mod.test.ts index 31220a6..f273b04 100644 --- a/common/empty.test.ts +++ b/empty/mod.test.ts @@ -1,9 +1,8 @@ import { assertEquals } from "@std/assert"; -import { Observer } from "@xan/observable-core"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { empty } from "./empty.ts"; -import { pipe } from "./pipe.ts"; +import { Observer } from "@observable/core"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { pipe } from "@observable/pipe"; +import { empty } from "./mod.ts"; Deno.test( "empty should return immediately when subscribed to without a signal", diff --git a/empty/mod.ts b/empty/mod.ts new file mode 100644 index 0000000..ad90c97 --- /dev/null +++ b/empty/mod.ts @@ -0,0 +1,24 @@ +import type { Observable } from "@observable/core"; +import { of } from "@observable/of"; + +/** + * An [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that calls [`return`](https://jsr.io/@observable/core/doc/~/Observer.return) + * immediately on [`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + * @example + * ```ts + * import { empty } from "@observable/empty"; + * + * const controller = new AbortController(); + * + * empty.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "return" + * ``` + */ +export const empty: Observable = of([]); diff --git a/exhaust-map/README.md b/exhaust-map/README.md new file mode 100644 index 0000000..bd979e0 --- /dev/null +++ b/exhaust-map/README.md @@ -0,0 +1,51 @@ +# @observable/exhaust-map + +Projects each [source](https://jsr.io/@observable/core#source) value to an +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which is merged in the output +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) only if the previous projected +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) has +[`return`](https://jsr.io/@observable/core/doc/~/Observer.return)ed. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { exhaustMap } from "@observable/exhaust-map"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { timer } from "@observable/timer"; +import { map } from "@observable/map"; + +const controller = new AbortController(); +const source = of([1, 2, 3]); + +pipe( + source, + exhaustMap((value) => pipe(timer(100), map(() => value))), +).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output (after 100ms): +// "next" 1 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/exhaust-map/deno.json b/exhaust-map/deno.json new file mode 100644 index 0000000..f84a888 --- /dev/null +++ b/exhaust-map/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/exhaust-map", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/exhaust-map.test.ts b/exhaust-map/mod.test.ts similarity index 96% rename from common/exhaust-map.test.ts rename to exhaust-map/mod.test.ts index 05c9e38..c4af00b 100644 --- a/common/exhaust-map.test.ts +++ b/exhaust-map/mod.test.ts @@ -1,17 +1,16 @@ import { assertEquals } from "@std/assert"; -import { Observable, Observer, Subject } from "@xan/observable-core"; -import { empty } from "./empty.ts"; -import { never } from "./never.ts"; -import { defer } from "./defer.ts"; -import { pipe } from "./pipe.ts"; -import { take } from "./take.ts"; -import { throwError } from "./throw-error.ts"; -import { BehaviorSubject } from "./behavior-subject.ts"; -import { flat } from "./flat.ts"; -import { exhaustMap } from "./exhaust-map.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { map } from "./map.ts"; +import { Observable, Observer, Subject } from "@observable/core"; +import { empty } from "@observable/empty"; +import { never } from "@observable/never"; +import { defer } from "@observable/defer"; +import { pipe } from "@observable/pipe"; +import { take } from "@observable/take"; +import { throwError } from "@observable/throw-error"; +import { BehaviorSubject } from "@observable/behavior-subject"; +import { flat } from "@observable/flat"; +import { exhaustMap } from "./mod.ts"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { map } from "@observable/map"; Deno.test( "exhaustMap should map-and-flatten each item to an Observable", diff --git a/exhaust-map/mod.ts b/exhaust-map/mod.ts new file mode 100644 index 0000000..090e0f5 --- /dev/null +++ b/exhaust-map/mod.ts @@ -0,0 +1,70 @@ +import { isObservable, type Observable, Observer } from "@observable/core"; +import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@observable/internal"; +import { defer } from "@observable/defer"; +import { pipe } from "@observable/pipe"; +import { tap } from "@observable/tap"; +import { filter } from "@observable/filter"; +import { switchMap } from "@observable/switch-map"; + +/** + * {@linkcode project|Projects} each [source](https://jsr.io/@observable/core#source) value to an + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which is merged in the output + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) only if the previous + * {@linkcode project|projected} [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) has + * [`return`](https://jsr.io/@observable/core/doc/~/Observer.return)ed. + * @example + * ```ts + * import { exhaustMap } from "@observable/exhaust-map"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * import { timer } from "@observable/timer"; + * import { map } from "@observable/map"; + * + * const controller = new AbortController(); + * const source = of([1, 2, 3]); + * + * pipe( + * source, + * exhaustMap((value) => pipe(timer(100), map(() => value))), + * ).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output (after 100ms): + * // "next" 1 + * // "return" + * ``` + */ +export function exhaustMap( + project: (value: In, index: number) => Observable, +): (source: Observable) => Observable { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (typeof project !== "function") { + throw new ParameterTypeError(0, "Function"); + } + return function exhaustMapFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + return defer(() => { + let activeInnerSubscription = false; + return pipe( + source, + filter(() => !activeInnerSubscription), + switchMap((value, index) => { + activeInnerSubscription = true; + return pipe( + project(value, index), + tap(new Observer({ return: processReturn, throw: noop })), + ); + + function processReturn(): void { + activeInnerSubscription = false; + } + }), + ); + }); + }; +} diff --git a/filter/README.md b/filter/README.md new file mode 100644 index 0000000..d812bb4 --- /dev/null +++ b/filter/README.md @@ -0,0 +1,42 @@ +# @observable/filter + +Filters [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values from the +[source](https://jsr.io/@observable/core#source) that satisfy a specified predicate. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { filter } from "@observable/filter"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe(of([1, 2, 3, 4, 5]), filter((value) => value % 2 === 0)).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 2 +// "next" 4 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/filter/deno.json b/filter/deno.json new file mode 100644 index 0000000..1d9584d --- /dev/null +++ b/filter/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/filter", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/filter.test.ts b/filter/mod.test.ts similarity index 87% rename from common/filter.test.ts rename to filter/mod.test.ts index ec026db..29c536a 100644 --- a/common/filter.test.ts +++ b/filter/mod.test.ts @@ -1,10 +1,9 @@ import { assertEquals } from "@std/assert"; -import { Observable, Observer } from "@xan/observable-core"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { filter } from "./filter.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; +import { Observable, Observer } from "@observable/core"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { filter } from "./mod.ts"; +import { materialize, type ObserverNotification } from "@observable/materialize"; Deno.test( "filter should filter the items emitted by the source observable", diff --git a/common/filter.ts b/filter/mod.ts similarity index 64% rename from common/filter.ts rename to filter/mod.ts index 453097f..af9395e 100644 --- a/common/filter.ts +++ b/filter/mod.ts @@ -1,27 +1,27 @@ -import { isObservable, Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; +import { isObservable, Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; /** - * Filters [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed values from the - * [source](https://jsr.io/@xan/observable-core#source) that satisfy a specified {@linkcode predicate}. + * Filters [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values from the + * [source](https://jsr.io/@observable/core#source) that satisfy a specified {@linkcode predicate}. * @example * ```ts - * import { filter, of, pipe } from "@xan/observable-common"; + * import { filter } from "@observable/filter"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; * * const controller = new AbortController(); * pipe(of([1, 2, 3, 4, 5]), filter((value) => value % 2 === 0)).subscribe({ * signal: controller.signal, - * next: (value) => console.log(value), + * next: (value) => console.log("next", value), * return: () => console.log("return"), - * throw: (value) => console.log(value), + * throw: (value) => console.log("throw", value), * }); * - * // console output: - * // 2 - * // 4 - * // return + * // Console output: + * // "next" 2 + * // "next" 4 + * // "return" * ``` */ export function filter( @@ -34,7 +34,7 @@ export function filter( return function filterFn(source) { if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - source = pipe(source, asObservable()); + source = toObservable(source); return new Observable((observer) => { let index = 0; source.subscribe({ diff --git a/finalize/README.md b/finalize/README.md new file mode 100644 index 0000000..788cf95 --- /dev/null +++ b/finalize/README.md @@ -0,0 +1,49 @@ +# @observable/finalize + +The [producer](https://jsr.io/@observable/core#producer) is notifying the +[consumer](https://jsr.io/@observable/core#consumer) that it's done +[`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ing values for any reason, and will +send no more values. Finalization, if it occurs, will always happen as a side-effect _after_ +[`return`](https://jsr.io/@observable/core/doc/~/Observer.return), +[`throw`](https://jsr.io/@observable/core/doc/~/Observer.throw), or +[`unsubscribe`](https://jsr.io/@observable/core/doc/~/Observer.signal) (whichever comes last). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { finalize } from "@observable/finalize"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe(of([1, 2, 3]), finalize(() => console.log("finalized"))).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 1 +// "next" 2 +// "next" 3 +// "return" +// "finalized" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/finalize/deno.json b/finalize/deno.json new file mode 100644 index 0000000..408d9ac --- /dev/null +++ b/finalize/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/finalize", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/finalize.test.ts b/finalize/mod.test.ts similarity index 60% rename from common/finalize.test.ts rename to finalize/mod.test.ts index eb14212..74eddc1 100644 --- a/common/finalize.test.ts +++ b/finalize/mod.test.ts @@ -1,27 +1,22 @@ import { assertEquals } from "@std/assert"; -import { Observable, Observer } from "@xan/observable-core"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { pipe } from "./pipe.ts"; -import { finalize } from "./finalize.ts"; -import { never } from "./never.ts"; +import { Observer } from "@observable/core"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { pipe } from "@observable/pipe"; +import { finalize } from "./mod.ts"; +import { flat } from "@observable/flat"; +import { of } from "@observable/of"; +import { throwError } from "@observable/throw-error"; Deno.test( "finalize should call the finalizer function after the source is returned", () => { // Arrange - const notifications: Array | [type: "F"]> = []; + const notifications: Array | [type: "finalize"]> = []; const values = [1, 2, 3] as const; const observable = pipe( - new Observable((observer) => { - for (const value of values) { - observer.next(value); - if (observer.signal.aborted) return; - } - observer.return(); - }), + of(values), + finalize(() => notifications.push(["finalize"])), materialize(), - finalize(() => notifications.push(["F"])), ); // Act @@ -32,8 +27,8 @@ Deno.test( // Assert assertEquals(notifications, [ ...values.map((value) => ["next", value] as const), + ["finalize"], ["return"], - ["F"], ]); }, ); @@ -43,15 +38,12 @@ Deno.test( () => { // Arrange const error = new Error("test"); - const notifications: Array | [type: "F"]> = []; + const notifications: Array | [type: "finalize"]> = []; const values = [1, 2, 3] as const; const observable = pipe( - new Observable((observer) => { - for (const value of values) observer.next(value); - observer.throw(error); - }), + flat([of(values), throwError(error)]), + finalize(() => notifications.push(["finalize"])), materialize(), - finalize(() => notifications.push(["F"])), ); // Act @@ -62,8 +54,8 @@ Deno.test( // Assert assertEquals(notifications, [ ...values.map((value) => ["next", value] as const), + ["finalize"], ["throw", error], - ["F"], ]); }, ); @@ -72,24 +64,28 @@ Deno.test( "finalize should call the finalizer function after the source is unsubscribed", () => { // Arrange - const notifications: Array | [type: "F"]> = []; + const notifications: Array | [type: "finalize"]> = []; const controller = new AbortController(); const observable = pipe( - never, + of([1, 2, 3]), + finalize(() => notifications.push(["finalize"])), materialize(), - finalize(() => notifications.push(["F"])), ); // Act observable.subscribe( new Observer({ - next: (notification) => notifications.push(notification), signal: controller.signal, + next: (notification) => { + notifications.push(notification); + if (notification[0] === "next" && notification[1] === 2) { + controller.abort(); + } + }, }), ); - controller.abort(); // Assert - assertEquals(notifications, [["F"]]); + assertEquals(notifications, [["next", 1], ["next", 2], ["finalize"]]); }, ); diff --git a/finalize/mod.ts b/finalize/mod.ts new file mode 100644 index 0000000..c699544 --- /dev/null +++ b/finalize/mod.ts @@ -0,0 +1,71 @@ +import { isObservable, Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; + +/** + * The [producer](https://jsr.io/@observable/core#producer) is notifying the [consumer](https://jsr.io/@observable/core#consumer) + * that it's done [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ing, values for any reason, and will send no more values. + * @example + * ```ts + * import { finalize } from "@observable/finalize"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * + * const controller = new AbortController(); + * pipe(of([1, 2, 3]), finalize(() => console.log("finalized"))).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "next" 1 + * // "next" 2 + * // "next" 3 + * // "finalized" + * // "return" + * ``` + * @example + * ```ts + * import { finalize } from "@observable/finalize"; + * import { throwError } from "@observable/throw-error"; + * import { pipe } from "@observable/pipe"; + * import { of } from "@observable/of"; + * import { flat } from "@observable/flat"; + * + * const controller = new AbortController(); + * const source = flat([of([1, 2, 3]), throwError(new Error("error"))]); + * pipe(source, finalize(() => console.log("finalized"))).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "next" 1 + * // "next" 2 + * // "next" 3 + * // "finalized" + * // "throw" Error: error + * ``` + */ +export function finalize( + teardown: () => void, +): (source: Observable) => Observable { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (typeof teardown !== "function") { + throw new ParameterTypeError(0, "Function"); + } + return function finalizeFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + source = toObservable(source); + return new Observable((observer) => { + observer.signal.addEventListener("abort", () => teardown(), { + once: true, + }); + source.subscribe(observer); + }); + }; +} diff --git a/flat-map/README.md b/flat-map/README.md new file mode 100644 index 0000000..fb52e38 --- /dev/null +++ b/flat-map/README.md @@ -0,0 +1,59 @@ +# @observable/flat-map + +Projects each [source](https://jsr.io/@observable/core#source) value to an +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which is merged in the output +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable), in a serialized fashion waiting +for each one to [`return`](https://jsr.io/@observable/core/doc/~/Observer.return) before merging the +next. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { flatMap } from "@observable/flat-map"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const source = of(["a", "b", "c"]); +const controller = new AbortController(); +const observableLookup = { + a: of([1, 2, 3]), + b: of([4, 5, 6]), + c: of([7, 8, 9]), +} as const; + +pipe(source, flatMap((value) => observableLookup[value])).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 1 +// "next" 2 +// "next" 3 +// "next" 4 +// "next" 5 +// "next" 6 +// "next" 7 +// "next" 8 +// "next" 9 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/flat-map/deno.json b/flat-map/deno.json new file mode 100644 index 0000000..0449de7 --- /dev/null +++ b/flat-map/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/flat-map", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/flat-map.test.ts b/flat-map/mod.test.ts similarity index 93% rename from common/flat-map.test.ts rename to flat-map/mod.test.ts index a8951b8..222fa8f 100644 --- a/common/flat-map.test.ts +++ b/flat-map/mod.test.ts @@ -1,10 +1,9 @@ import { assertEquals } from "@std/assert"; -import { type Observable, Observer, Subject } from "@xan/observable-core"; -import { pipe } from "./pipe.ts"; -import { flatMap } from "./flat-map.ts"; -import { map } from "./map.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; +import { type Observable, Observer, Subject } from "@observable/core"; +import { pipe } from "@observable/pipe"; +import { flatMap } from "./mod.ts"; +import { map } from "@observable/map"; +import { materialize, type ObserverNotification } from "@observable/materialize"; Deno.test("flatMap should flatten many inners", () => { // Arrange diff --git a/common/flat-map.ts b/flat-map/mod.ts similarity index 71% rename from common/flat-map.ts rename to flat-map/mod.ts index c095aa9..a45abdf 100644 --- a/common/flat-map.ts +++ b/flat-map/mod.ts @@ -1,17 +1,17 @@ -import { isObservable, Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; +import { isObservable, Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; /** - * {@linkcode project|Projects} each [source](https://jsr.io/@xan/observable-core#source) value to an - * [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) which is merged in the output - * [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable), in a serialized fashion - * waiting for each one to [`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return) before + * {@linkcode project|Projects} each [source](https://jsr.io/@observable/core#source) value to an + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which is merged in the output + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable), in a serialized fashion + * waiting for each one to [`return`](https://jsr.io/@observable/core/doc/~/Observer.return) before * merging the next. * @example * ```ts - * import { flatMap, pipe, of } from "@xan/observable-common"; + * import { flatMap } from "@observable/flat-map"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; * * const source = of(["a", "b", "c"]); * const controller = new AbortController(); @@ -23,22 +23,22 @@ import { asObservable } from "./as-observable.ts"; * * pipe(source, flatMap((value) => observableLookup[value])).subscribe({ * signal: controller.signal, - * next: (value) => console.log(value), + * next: (value) => console.log("next", value), * return: () => console.log("return"), * throw: (value) => console.log("throw", value), * }); * * // Console output: - * // 1 - * // 2 - * // 3 - * // 4 - * // 5 - * // 6 - * // 7 - * // 8 - * // 9 - * // return + * // "next" 1 + * // "next" 2 + * // "next" 3 + * // "next" 4 + * // "next" 5 + * // "next" 6 + * // "next" 7 + * // "next" 8 + * // "next" 9 + * // "return" * ``` */ export function flatMap( @@ -51,7 +51,7 @@ export function flatMap( return function flatMapFn(source) { if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - source = pipe(source, asObservable()); + source = toObservable(source); return new Observable((observer) => { let index = 0; let activeInnerSubscription = false; @@ -81,7 +81,7 @@ export function flatMap( }); function processNextValue(value: In): void { - pipe(project(value, index++), asObservable()).subscribe({ + project(value, index++).subscribe({ signal: observer.signal, next: (value) => observer.next(value), return() { diff --git a/flat/README.md b/flat/README.md new file mode 100644 index 0000000..7457f27 --- /dev/null +++ b/flat/README.md @@ -0,0 +1,51 @@ +# @observable/flat + +Creates an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which sequentially emits +all values from the first given [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) and +then moves on to the next. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { flat } from "@observable/flat"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); + +flat([of([1, 2, 3]), of([4, 5, 6]), of([7, 8, 9])]).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 1 +// "next" 2 +// "next" 3 +// "next" 4 +// "next" 5 +// "next" 6 +// "next" 7 +// "next" 8 +// "next" 9 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/flat/deno.json b/flat/deno.json new file mode 100644 index 0000000..86ad20b --- /dev/null +++ b/flat/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/flat", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/flat.test.ts b/flat/mod.test.ts similarity index 90% rename from common/flat.test.ts rename to flat/mod.test.ts index cce1abc..130061b 100644 --- a/common/flat.test.ts +++ b/flat/mod.test.ts @@ -1,10 +1,9 @@ import { assertEquals } from "@std/assert"; -import { Observer, Subject } from "@xan/observable-core"; -import { pipe } from "./pipe.ts"; -import { throwError } from "./throw-error.ts"; -import { flat } from "./flat.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; +import { Observer, Subject } from "@observable/core"; +import { pipe } from "@observable/pipe"; +import { throwError } from "@observable/throw-error"; +import { flat } from "@observable/flat"; +import { materialize, type ObserverNotification } from "@observable/materialize"; Deno.test("flat should flatten many inners", () => { // Arrange diff --git a/common/flat.ts b/flat/mod.ts similarity index 52% rename from common/flat.ts rename to flat/mod.ts index d4da9bb..f35b292 100644 --- a/common/flat.ts +++ b/flat/mod.ts @@ -1,41 +1,43 @@ -import type { Observable } from "@xan/observable-core"; +import type { Observable } from "@observable/core"; import { identity, isIterable, MinimumArgumentsRequiredError, ParameterTypeError, -} from "@xan/observable-internal"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { flatMap } from "./flat-map.ts"; +} from "@observable/internal"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { flatMap } from "@observable/flat-map"; /** - * Creates an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) which sequentially emits all values from the first given - * [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) and then moves on to the next. + * Creates an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which sequentially emits all values from the first given + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) and then moves on to the next. * @example * ```ts - * import { flat, pipe, of } from "@xan/observable-common"; + * import { flat } from "@observable/flat"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; * * const controller = new AbortController(); * * flat(of([1, 2, 3]), of([4, 5, 6]), of([7, 8, 9])).subscribe({ * signal: controller.signal, - * next: (value) => console.log(value), + * next: (value) => console.log("next", value), * return: () => console.log("return"), * throw: (value) => console.log("throw", value), * }); * * // Console output: - * // 1 - * // 2 - * // 3 - * // 4 - * // 5 - * // 6 - * // 7 - * // 8 - * // 9 - * // return + * // "next" 1 + * // "next" 2 + * // "next" 3 + * // "next" 4 + * // "next" 5 + * // "next" 6 + * // "next" 7 + * // "next" 8 + * // "next" 9 + * // "return" * ``` */ export function flat( diff --git a/ignore-elements/README.md b/ignore-elements/README.md new file mode 100644 index 0000000..e25707f --- /dev/null +++ b/ignore-elements/README.md @@ -0,0 +1,40 @@ +# @observable/ignore-elements + +Ignores all [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values from the +[source](https://jsr.io/@observable/core#source). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { ignoreElements } from "@observable/ignore-elements"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe(of([1, 2, 3, 4, 5]), ignoreElements()).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/ignore-elements/deno.json b/ignore-elements/deno.json new file mode 100644 index 0000000..19f9c70 --- /dev/null +++ b/ignore-elements/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/ignore-elements", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/ignore-elements.test.ts b/ignore-elements/mod.test.ts similarity index 84% rename from common/ignore-elements.test.ts rename to ignore-elements/mod.test.ts index a55561b..224acdb 100644 --- a/common/ignore-elements.test.ts +++ b/ignore-elements/mod.test.ts @@ -1,10 +1,9 @@ import { assertEquals } from "@std/assert"; -import { Observable, Observer, Subject } from "@xan/observable-core"; -import { pipe } from "./pipe.ts"; -import { of } from "./of.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { ignoreElements } from "./ignore-elements.ts"; +import { Observable, Observer, Subject } from "@observable/core"; +import { pipe } from "@observable/pipe"; +import { of } from "@observable/of"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { ignoreElements } from "./mod.ts"; Deno.test( "ignoreElements should ignore all next values but pass return", diff --git a/common/ignore-elements.ts b/ignore-elements/mod.ts similarity index 58% rename from common/ignore-elements.ts rename to ignore-elements/mod.ts index f5e1ea6..2153717 100644 --- a/common/ignore-elements.ts +++ b/ignore-elements/mod.ts @@ -1,25 +1,25 @@ -import { isObservable, Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@xan/observable-internal"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; +import { isObservable, Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@observable/internal"; /** - * Ignores all [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed values from the - * [source](https://jsr.io/@xan/observable-core#source). + * Ignores all [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values from the + * [source](https://jsr.io/@observable/core#source). * @example * ```ts - * import { ignoreElements, of, pipe } from "@xan/observable-common"; + * import { ignoreElements } from "@observable/ignore-elements"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; * * const controller = new AbortController(); * pipe(of([1, 2, 3, 4, 5]), ignoreElements()).subscribe({ * signal: controller.signal, - * next: (value) => console.log(value), + * next: (value) => console.log("next", value), * return: () => console.log("return"), - * throw: (value) => console.log(value), + * throw: (value) => console.log("throw", value), * }); * - * // console output: - * // return + * // Console output: + * // "return" * ``` */ export function ignoreElements(): ( @@ -28,7 +28,7 @@ export function ignoreElements(): ( return function ignoreElementsFn(source) { if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - source = pipe(source, asObservable()); + source = toObservable(source); return new Observable((observer) => source.subscribe({ signal: observer.signal, diff --git a/internal/README.md b/internal/README.md index 61a6952..97b388e 100644 --- a/internal/README.md +++ b/internal/README.md @@ -1,4 +1,17 @@ -# @xan/observable-internal +# @observable/internal -Internal utilities for the `Observable` libraries. Do NOT depend on this library directly as it's an -internal implementation detail. +Internal utilities for the [@observable](https://jsr.io/@observable) libraries. Do NOT depend on +this library directly as it's an internal implementation detail. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). diff --git a/internal/deno.json b/internal/deno.json index 916d270..16e3e74 100644 --- a/internal/deno.json +++ b/internal/deno.json @@ -1,5 +1,5 @@ { - "name": "@xan/observable-internal", + "name": "@observable/internal", "version": "0.1.0", "license": "MIT", "exports": "./mod.ts" diff --git a/internal/is-abort-signal.ts b/internal/is-abort-signal.ts index b2f79fb..0c2d9d4 100644 --- a/internal/is-abort-signal.ts +++ b/internal/is-abort-signal.ts @@ -5,7 +5,7 @@ import { MinimumArgumentsRequiredError } from "./minimum-arguments-required-erro * Checks if a {@linkcode value} is an object that implements the {@linkcode AbortSignal} interface. * @example * ```ts - * import { isAbortSignal } from "@xan/observable-internal"; + * import { isAbortSignal } from "@observable/internal"; * * const abortSignalInstance = new AbortController().signal; * isAbortSignal(abortSignalInstance); // true diff --git a/internal/is-event-target.ts b/internal/is-event-target.ts index d595460..c0baf8d 100644 --- a/internal/is-event-target.ts +++ b/internal/is-event-target.ts @@ -1,11 +1,11 @@ -import { MinimumArgumentsRequiredError } from "@xan/observable-internal"; +import { MinimumArgumentsRequiredError } from "./minimum-arguments-required-error.ts"; import { isObject } from "./is-object.ts"; /** * Checks if a {@linkcode value} is an object that implements the {@linkcode EventTarget} interface. * @example * ```ts - * import { isEventTarget } from "@xan/observable-internal"; + * import { isEventTarget } from "@observable/internal"; * * const eventTargetInstance = new EventTarget(); * isEventTarget(eventTargetInstance); // true diff --git a/internal/is-iterable.ts b/internal/is-iterable.ts index 53897c6..7e2ac8f 100644 --- a/internal/is-iterable.ts +++ b/internal/is-iterable.ts @@ -5,7 +5,7 @@ import { isObject } from "./is-object.ts"; * Checks if a {@linkcode value} is an object that implements the {@linkcode Iterable} interface. * @example * ```ts - * import { isIterable } from "@xan/observable-internal"; + * import { isIterable } from "@observable/internal"; * * const iterableLiteral: Iterable = { * [Symbol.iterator]() { diff --git a/internal/is-nil.ts b/internal/is-nil.ts index 0cc10ef..fee8e37 100644 --- a/internal/is-nil.ts +++ b/internal/is-nil.ts @@ -1,10 +1,10 @@ -import { MinimumArgumentsRequiredError } from "@xan/observable-internal"; +import { MinimumArgumentsRequiredError } from "./minimum-arguments-required-error.ts"; /** * Checks if a {@linkcode value} is `null` or `undefined`. * @example * ```ts - * import { isNil } from "@xan/observable-internal"; + * import { isNil } from "@observable/internal"; * * isNil(undefined); // true * isNil(null); // true diff --git a/internal/is-object.ts b/internal/is-object.ts index ffb8170..ab7542d 100644 --- a/internal/is-object.ts +++ b/internal/is-object.ts @@ -4,7 +4,7 @@ import { MinimumArgumentsRequiredError } from "./minimum-arguments-required-erro * Checks if a {@linkcode value} is an `object`. * @example * ```ts - * import { isObject } from "@xan/observable-internal"; + * import { isObject } from "@observable/internal"; * * isObject({}); // true * isObject(null)); // false diff --git a/interval/README.md b/interval/README.md new file mode 100644 index 0000000..ed6e364 --- /dev/null +++ b/interval/README.md @@ -0,0 +1,69 @@ +# @observable/interval + +Creates an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that emits an index +value after a specific number of milliseconds, repeatedly. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { interval } from "@observable/interval"; +import { take } from "@observable/take"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe(interval(1000), take(3)).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output (after 1 second): +// "next" 0 +// Console output (after 2 seconds): +// "next" 1 +// Console output (after 3 seconds): +// "next" 2 +// "return" +``` + +## Edge cases + +```ts +import { interval } from "@observable/interval"; + +const controller = new AbortController(); + +// 0ms interval emits synchronously +interval(0).subscribe({ + signal: controller.signal, + next: (value) => { + console.log("next", value); + if (value === 2) controller.abort(); + }, + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output (synchronously): +// "next" 0 +// "next" 1 +// "next" 2 +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/interval/deno.json b/interval/deno.json new file mode 100644 index 0000000..2227d3f --- /dev/null +++ b/interval/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/interval", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/interval/mod.test.ts b/interval/mod.test.ts new file mode 100644 index 0000000..85c4eb3 --- /dev/null +++ b/interval/mod.test.ts @@ -0,0 +1,273 @@ +import { assertEquals, assertInstanceOf, assertStrictEquals, assertThrows } from "@std/assert"; +import { Observer } from "@observable/core"; +import { empty } from "@observable/empty"; +import { never } from "@observable/never"; +import { pipe } from "@observable/pipe"; +import { take } from "@observable/take"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { interval } from "./mod.ts"; + +Deno.test("interval should return never if the milliseconds is Infinity", () => { + // Arrange / Act + const observable = interval(Infinity); + + // Assert + assertStrictEquals(observable, never); +}); + +Deno.test("interval should return empty if the milliseconds is negative", () => { + // Arrange / Act + const observable = interval(-1); + + // Assert + assertStrictEquals(observable, empty); +}); + +Deno.test("interval should return empty if the milliseconds is NaN", () => { + // Arrange / Act + const observable = interval(NaN); + + // Assert + assertStrictEquals(observable, empty); +}); + +Deno.test("interval should emit indexes synchronously when milliseconds is 0", () => { + // Arrange + const notifications: Array> = []; + const observable = pipe(interval(0), take(5), materialize()); + + // Act + observable.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 0], + ["next", 1], + ["next", 2], + ["next", 3], + ["next", 4], + ["return"], + ]); +}); + +Deno.test("interval should setup an interval timer", () => { + // Arrange + let overrode = true; + const milliseconds = 1_000; + const intervalId = Math.random(); + const notifications: Array> = []; + const setIntervalCalls: Array> = []; + const originalSetInterval = globalThis.setInterval; + Object.defineProperty(globalThis, "setInterval", { + value: (...args: Parameters) => { + setIntervalCalls.push(args); + return overrode ? intervalId : originalSetInterval(...args); + }, + }); + + // Act + pipe(interval(milliseconds), materialize()).subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertStrictEquals(setIntervalCalls.length, 1); + assertEquals(notifications, []); + const [[callback, delay]] = setIntervalCalls; + assertStrictEquals(delay, milliseconds); + assertInstanceOf(callback, Function); + callback(); + assertEquals(notifications, [["next", 0]]); + callback(); + assertEquals(notifications, [["next", 0], ["next", 1]]); + callback(); + assertEquals(notifications, [["next", 0], ["next", 1], ["next", 2]]); + + overrode = false; +}); + +Deno.test("interval should clear interval on unsubscription", () => { + // Arrange + let overrideGlobals = true; + const milliseconds = 1_000; + const intervalId = Math.random(); + const setIntervalCalls: Array> = []; + const clearIntervalCalls: Array> = []; + const originalSetInterval = globalThis.setInterval; + const originalClearInterval = globalThis.clearInterval; + const controller = new AbortController(); + Object.defineProperty(globalThis, "setInterval", { + value: (...args: Parameters) => { + setIntervalCalls.push(args); + return overrideGlobals ? intervalId : originalSetInterval(...args); + }, + }); + Object.defineProperty(globalThis, "clearInterval", { + value: (...args: Parameters) => { + clearIntervalCalls.push(args); + return overrideGlobals ? undefined : originalClearInterval(...args); + }, + }); + + // Act + interval(milliseconds).subscribe( + new Observer({ signal: controller.signal }), + ); + controller.abort(); + + // Assert + assertStrictEquals(setIntervalCalls.length, 1); + assertEquals(clearIntervalCalls, [[intervalId]]); + overrideGlobals = false; +}); + +Deno.test( + "interval should clear interval on unsubscription before subscription is created", + () => { + // Arrange + let overrideGlobals = true; + const milliseconds = 1_000; + const setIntervalCalls: Array> = []; + const clearIntervalCalls: Array> = []; + const originalSetInterval = globalThis.setInterval; + const originalClearInterval = globalThis.clearInterval; + const controller = new AbortController(); + Object.defineProperty(globalThis, "setInterval", { + value: (...args: Parameters) => { + setIntervalCalls.push(args); + return overrideGlobals ? Math.random() : originalSetInterval(...args); + }, + }); + Object.defineProperty(globalThis, "clearInterval", { + value: (...args: Parameters) => { + clearIntervalCalls.push(args); + return overrideGlobals ? undefined : originalClearInterval(...args); + }, + }); + controller.abort(); + + // Act + interval(milliseconds).subscribe( + new Observer({ signal: controller.signal }), + ); + + // Assert + assertEquals(setIntervalCalls, []); + assertEquals(clearIntervalCalls, []); + overrideGlobals = false; + }, +); + +Deno.test( + "interval should throw when called with no arguments", + () => { + // Arrange / Act / Assert + assertThrows( + () => interval(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); + }, +); + +Deno.test( + "interval should throw when milliseconds is not a number", + () => { + // Arrange / Act / Assert + assertThrows( + () => interval("s" as unknown as number), + TypeError, + "Parameter 1 is not of type 'Number'", + ); + }, +); + +Deno.test("interval should emit increasing indexes", () => { + // Arrange + let overrode = true; + const milliseconds = 100; + const intervalId = Math.random(); + const notifications: Array> = []; + const setIntervalCalls: Array> = []; + const originalSetInterval = globalThis.setInterval; + const controller = new AbortController(); + Object.defineProperty(globalThis, "setInterval", { + value: (...args: Parameters) => { + setIntervalCalls.push(args); + return overrode ? intervalId : originalSetInterval(...args); + }, + }); + + // Act + pipe(interval(milliseconds), materialize()).subscribe( + new Observer({ + signal: controller.signal, + next: (notification) => notifications.push(notification), + }), + ); + const [[callback]] = setIntervalCalls; + for (let i = 0; i < 10; i++) { + (callback as () => void)(); + } + controller.abort(); + + // Assert + assertEquals(notifications, [ + ["next", 0], + ["next", 1], + ["next", 2], + ["next", 3], + ["next", 4], + ["next", 5], + ["next", 6], + ["next", 7], + ["next", 8], + ["next", 9], + ]); + overrode = false; +}); + +Deno.test("interval should work with take operator", () => { + // Arrange + let overrode = true; + const milliseconds = 100; + const intervalId = Math.random(); + const notifications: Array> = []; + const setIntervalCalls: Array> = []; + const clearIntervalCalls: Array> = []; + const originalSetInterval = globalThis.setInterval; + const originalClearInterval = globalThis.clearInterval; + Object.defineProperty(globalThis, "setInterval", { + value: (...args: Parameters) => { + setIntervalCalls.push(args); + return overrode ? intervalId : originalSetInterval(...args); + }, + }); + Object.defineProperty(globalThis, "clearInterval", { + value: (...args: Parameters) => { + clearIntervalCalls.push(args); + return overrode ? undefined : originalClearInterval(...args); + }, + }); + + // Act + pipe(interval(milliseconds), take(3), materialize()).subscribe( + new Observer((notification) => notifications.push(notification)), + ); + const [[callback]] = setIntervalCalls; + for (let i = 0; i < 5; i++) { + (callback as () => void)(); + } + + // Assert + assertEquals(notifications, [ + ["next", 0], + ["next", 1], + ["next", 2], + ["return"], + ]); + assertEquals(clearIntervalCalls, [[intervalId]]); + overrode = false; +}); diff --git a/interval/mod.ts b/interval/mod.ts new file mode 100644 index 0000000..a5f9311 --- /dev/null +++ b/interval/mod.ts @@ -0,0 +1,73 @@ +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; +import { Observable } from "@observable/core"; +import { defer } from "@observable/defer"; +import { map } from "@observable/map"; +import { pipe } from "@observable/pipe"; +import { of } from "@observable/of"; +import { empty } from "@observable/empty"; +import { never } from "@observable/never"; + +/** + * @internal Do NOT export. + */ +const indexes = pipe( + defer(() => of(generateInfiniteVoid())), + map((_, index) => index), +); + +/** + * Creates an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that emits an index value after a specific + * number of {@linkcode milliseconds}, repeatedly. + * @example + * ```ts + * import { interval } from "@observable/interval"; + * import { take } from "@observable/take"; + * import { pipe } from "@observable/pipe"; + * + * const controller = new AbortController(); + * pipe(interval(1000), take(3)).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output (after 1 second): + * // "next" 0 + * // Console output (after 2 seconds): + * // "next" 1 + * // Console output (after 3 seconds): + * // "next" 2 + * // "return" + * ``` + */ +export function interval(milliseconds: number): Observable { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (typeof milliseconds !== "number") { + throw new ParameterTypeError(0, "Number"); + } + if (milliseconds < 0 || Number.isNaN(milliseconds)) return empty; + if (milliseconds === 0) return indexes; + if (milliseconds === Infinity) return never; + return pipe( + new Observable((observer) => { + const interval = globalThis.setInterval( + () => observer.next(), + milliseconds, + ); + observer.signal.addEventListener( + "abort", + () => globalThis.clearInterval(interval), + { once: true }, + ); + }), + map((_, index) => index), + ); +} + +/** + * @internal Do NOT export. + */ +function* generateInfiniteVoid(): Generator { + while (true) yield; +} diff --git a/keep-alive/README.md b/keep-alive/README.md new file mode 100644 index 0000000..77589a0 --- /dev/null +++ b/keep-alive/README.md @@ -0,0 +1,45 @@ +# @observable/keep-alive + +Ignores [`unsubscribe`](https://jsr.io/@observable/core/doc/~/Observer.signal) indefinitely. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { keepAlive } from "@observable/keep-alive"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe(of([1, 2, 3]), keepAlive()).subscribe({ + signal: controller.signal, + next: (value) => { + console.log("next", value); + if (value === 2) controller.abort(); // Ignored + }, + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 1 +// "next" 2 +// "next" 3 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/keep-alive/deno.json b/keep-alive/deno.json new file mode 100644 index 0000000..0e4c66f --- /dev/null +++ b/keep-alive/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/keep-alive", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/keep-alive/mod.test.ts b/keep-alive/mod.test.ts new file mode 100644 index 0000000..d007211 --- /dev/null +++ b/keep-alive/mod.test.ts @@ -0,0 +1,39 @@ +import { assertEquals, assertStrictEquals } from "@std/assert"; +import { Observer } from "@observable/core"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { pipe } from "@observable/pipe"; +import { keepAlive } from "./mod.ts"; +import { of } from "@observable/of"; +import { tap } from "@observable/tap"; + +Deno.test("keepAlive should ignore unsubscribe indefinitely", () => { + // Arrange + const controller = new AbortController(); + const tapNotifications: Array> = []; + const observerNotifications: Array> = []; + const source = of([1, 2, 3]); + + // Act + pipe( + source, + materialize(), + tap(new Observer((notification) => tapNotifications.push(notification))), + keepAlive(), + ).subscribe( + new Observer({ + signal: controller.signal, + next: (notification) => { + observerNotifications.push(notification); + if (notification[1] === 2) controller.abort(); + }, + }), + ); + + // Assert + assertStrictEquals(controller.signal.aborted, true); + assertEquals(observerNotifications, [ + ["next", 1], + ["next", 2], + ]); + assertEquals(tapNotifications, [["next", 1], ["next", 2], ["next", 3], ["return"]]); +}); diff --git a/keep-alive/mod.ts b/keep-alive/mod.ts new file mode 100644 index 0000000..2fcbaa2 --- /dev/null +++ b/keep-alive/mod.ts @@ -0,0 +1,48 @@ +import { isObservable, Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; + +const { signal: noopSignal } = new AbortController(); + +/** + * Ignores [`unsubscribe`](https://jsr.io/@observable/core/doc/~/Observer.signal) indefinitely. + * @example + * ```ts + * import { keepAlive } from "@observable/keep-alive"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * + * const controller = new AbortController(); + * pipe(of([1, 2, 3]), keepAlive()).subscribe({ + * signal: controller.signal, + * next: (value) => { + * console.log("next", value); + * if (value === 2) controller.abort(); // Ignored + * }, + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "next" 1 + * // "next" 2 + * // "next" 3 + * // "return" + * ``` + */ +export function keepAlive(): ( + source: Observable, +) => Observable { + return function keepAliveFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + source = toObservable(source); + return new Observable((observer) => + source.subscribe({ + signal: noopSignal, + next: (value) => observer.next(value), + return: () => observer.return(), + throw: (value) => observer.throw(value), + }) + ); + }; +} diff --git a/map/README.md b/map/README.md new file mode 100644 index 0000000..c36498b --- /dev/null +++ b/map/README.md @@ -0,0 +1,43 @@ +# @observable/map + +Projects each value from the [source](https://jsr.io/@observable/core#source) to a new value. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { map } from "@observable/map"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); + +pipe(of([1, 2, 3]), map((value) => value * 2)).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 2 +// "next" 4 +// "next" 6 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/map/deno.json b/map/deno.json new file mode 100644 index 0000000..56a9c47 --- /dev/null +++ b/map/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/map", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/map.test.ts b/map/mod.test.ts similarity index 88% rename from common/map.test.ts rename to map/mod.test.ts index f2d2b78..48e70ac 100644 --- a/common/map.test.ts +++ b/map/mod.test.ts @@ -1,11 +1,10 @@ import { assertEquals } from "@std/assert"; -import { Observable, Observer } from "@xan/observable-core"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { throwError } from "./throw-error.ts"; -import { map } from "./map.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; +import { Observable, Observer } from "@observable/core"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { throwError } from "@observable/throw-error"; +import { map } from "./mod.ts"; +import { materialize, type ObserverNotification } from "@observable/materialize"; Deno.test("map should project the values", () => { // Arrange diff --git a/common/map.ts b/map/mod.ts similarity index 70% rename from common/map.ts rename to map/mod.ts index 1193df0..c8d7a0d 100644 --- a/common/map.ts +++ b/map/mod.ts @@ -1,29 +1,29 @@ -import { isObservable, Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; +import { isObservable, Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; /** - * {@linkcode project|Projects} each {@linkcode In|value} from the [source](https://jsr.io/@xan/observable-core#source) + * {@linkcode project|Projects} each {@linkcode In|value} from the [source](https://jsr.io/@observable/core#source) * to a new {@linkcode Out|value}. * @example * ```ts - * import { map, pipe, of } from "@xan/observable-common"; + * import { map } from "@observable/map"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; * * const controller = new AbortController(); * - * pipe(of([1, 2, 3]), map((x) => x * 2)).subscribe({ + * pipe(of([1, 2, 3]), map((value) => value * 2)).subscribe({ * signal: controller.signal, - * next: (value) => console.log(value), + * next: (value) => console.log("next", value), * return: () => console.log("return"), * throw: (value) => console.log("throw", value), * }); * * // Console output: - * // 2 - * // 4 - * // 6 - * // return + * // "next" 2 + * // "next" 4 + * // "next" 6 + * // "return" * ``` */ export function map( @@ -36,7 +36,7 @@ export function map( return function mapFn(source) { if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - source = pipe(source, asObservable()); + source = toObservable(source); return new Observable((observer) => { let index = 0; source.subscribe({ diff --git a/materialize/README.md b/materialize/README.md new file mode 100644 index 0000000..25448d1 --- /dev/null +++ b/materialize/README.md @@ -0,0 +1,85 @@ +# @observable/materialize + +Represents all of the notifications from the [source](https://jsr.io/@observable/core#source) as +[`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values marked with their original +types within notification entries. This is especially useful for testing, debugging, and logging. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { materialize } from "@observable/materialize"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe(of([1, 2, 3]), materialize()).subscribe({ + signal: controller.signal, + next: (value) => console.log(value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// ["next", 1] +// ["next", 2] +// ["next", 3] +// ["return"] +// "return" +``` + +## Unit testing example + +```ts +import { materialize, ObserverNotification } from "@observable/materialize"; +import { pipe } from "@observable/pipe"; +import { of } from "@observable/of"; +import { Observer } from "@observable/core"; + +const observable = of([1, 2, 3]); + +describe("observable", () => { + let activeSubscriptionController: AbortController; + + beforeEach(() => (activeSubscriptionController = new AbortController())); + + afterEach(() => activeSubscriptionController?.abort()); + + it("should emit the notifications", () => { + // Arrange + const notifications: Array> = []; + + // Act + pipe(observable, materialize()).subscribe( + new Observer({ + signal: activeSubscriptionController.signal, + next: (notification) => notifications.push(notification), + }), + ); + + // Assert + expect(notifications).toEqual([ + ["next", 1], + ["next", 2], + ["next", 3], + ["return"], + ]); + }); +}); +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/materialize/deno.json b/materialize/deno.json new file mode 100644 index 0000000..2f149b2 --- /dev/null +++ b/materialize/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/materialize", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/materialize.test.ts b/materialize/mod.test.ts similarity index 89% rename from common/materialize.test.ts rename to materialize/mod.test.ts index ffaf488..7e84299 100644 --- a/common/materialize.test.ts +++ b/materialize/mod.test.ts @@ -1,10 +1,9 @@ import { assertEquals } from "@std/assert"; -import { Observer } from "@xan/observable-core"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { throwError } from "./throw-error.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; +import { Observer } from "@observable/core"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { throwError } from "@observable/throw-error"; +import { materialize, type ObserverNotification } from "./mod.ts"; Deno.test( "materialize should emit the notifications from a source observable that returns", diff --git a/common/materialize.ts b/materialize/mod.ts similarity index 66% rename from common/materialize.ts rename to materialize/mod.ts index 4bd0f7b..1aac7a0 100644 --- a/common/materialize.ts +++ b/materialize/mod.ts @@ -1,18 +1,29 @@ -import { isObservable, Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; +import { isObservable, Observable, type Observer, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; + +/** + * Represents any type of [`Observer`](https://jsr.io/@observable/core/doc/~/Observer) notification + * ([`next`](https://jsr.io/@observable/core/doc/~/Observer.next), + * [`return`](https://jsr.io/@observable/core/doc/~/Observer.return), or + * [`throw`](https://jsr.io/@observable/core/doc/~/Observer.throw)). + */ +export type ObserverNotification = Readonly< + | [type: Extract<"next", keyof Observer>, value: Value] + | [type: Extract<"return", keyof Observer>] + | [type: Extract<"throw", keyof Observer>, value: unknown] +>; /** * Represents all of the {@linkcode ObserverNotification|notifications} from the - * [source](https://jsr.io/@xan/observable-core#source) as - * [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed values + * [source](https://jsr.io/@observable/core#source) as + * [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values * marked with their original types within {@linkcode ObserverNotification|notification} entries. * This is especially useful for testing, debugging, and logging. * @example * ```ts - * import { materialize, of, pipe } from "@xan/observable-common"; + * import { materialize } from "@observable/materialize"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; * * const controller = new AbortController(); * pipe(of([1, 2, 3]), materialize()).subscribe({ @@ -31,7 +42,9 @@ import { asObservable } from "./as-observable.ts"; * ``` * @example * ```ts - * import { throwError, of, materialize, pipe } from "@xan/observable-common"; + * import { materialize } from "@observable/materialize"; + * import { throwError } from "@observable/throw-error"; + * import { pipe } from "@observable/pipe"; * * const controller = new AbortController(); * pipe(throwError(new Error("error")), materialize()).subscribe({ @@ -48,7 +61,10 @@ import { asObservable } from "./as-observable.ts"; * @example * Unit testing * ```ts - * import { materialize, pipe, of } from "@xan/observable-common"; + * import { materialize, ObserverNotification } from "@observable/materialize"; + * import { pipe } from "@observable/pipe"; + * import { of } from "@observable/of"; + * import { Observer } from "@observable/core"; * * const observable = of([1, 2, 3]); * @@ -88,7 +104,7 @@ export function materialize(): ( return function materializeFn(source) { if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - source = pipe(source, asObservable()); + source = toObservable(source); return new Observable((observer) => source.subscribe({ signal: observer.signal, diff --git a/merge-map/README.md b/merge-map/README.md new file mode 100644 index 0000000..0f7d545 --- /dev/null +++ b/merge-map/README.md @@ -0,0 +1,58 @@ +# @observable/merge-map + +Projects each [source](https://jsr.io/@observable/core#source) value to an +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which is merged in the output +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { mergeMap } from "@observable/merge-map"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +const observableLookup = { + 1: of([1, 2, 3]), + 2: of([4, 5, 6]), + 3: of([7, 8, 9]), +} as const; +pipe( + of([1, 2, 3]), + mergeMap((value) => observableLookup[value]), +).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 1 +// "next" 2 +// "next" 3 +// "next" 4 +// "next" 5 +// "next" 6 +// "next" 7 +// "next" 8 +// "next" 9 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/merge-map/deno.json b/merge-map/deno.json new file mode 100644 index 0000000..e3203c7 --- /dev/null +++ b/merge-map/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/merge-map", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/merge-map.test.ts b/merge-map/mod.test.ts similarity index 95% rename from common/merge-map.test.ts rename to merge-map/mod.test.ts index 65ded9a..76e5e3f 100644 --- a/common/merge-map.test.ts +++ b/merge-map/mod.test.ts @@ -1,11 +1,10 @@ import { assertEquals } from "@std/assert"; -import { Observable, Observer, Subject } from "@xan/observable-core"; -import { noop } from "@xan/observable-internal"; -import { pipe } from "./pipe.ts"; -import { map } from "./map.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { mergeMap } from "./merge-map.ts"; +import { Observable, Observer, Subject } from "@observable/core"; +import { noop } from "@observable/internal"; +import { pipe } from "@observable/pipe"; +import { map } from "@observable/map"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { mergeMap } from "./mod.ts"; Deno.test("mergeMap should map-and-flatten each item to an Observable", () => { // Arrange diff --git a/common/merge-map.ts b/merge-map/mod.ts similarity index 53% rename from common/merge-map.ts rename to merge-map/mod.ts index 9e54236..9df9826 100644 --- a/common/merge-map.ts +++ b/merge-map/mod.ts @@ -1,11 +1,41 @@ -import { isObservable, Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; +import { isObservable, Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; /** - * {@linkcode project|Projects} each source value to an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) - * which is merged in the output [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable). + * {@linkcode project|Projects} each [source](https://jsr.io/@observable/core#source) value to an + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which is merged in the output + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable). + * @example + * ```ts + * import { mergeMap } from "@observable/merge-map"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * + * const controller = new AbortController(); + * const observableLookup = { + * 1: of([1, 2, 3]), + * 2: of([4, 5, 6]), + * 3: of([7, 8, 9]), + * } as const; + * pipe(of([1, 2, 3]), mergeMap((value) => observableLookup[value])).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "next" 1 + * // "next" 2 + * // "next" 3 + * // "next" 4 + * // "next" 5 + * // "next" 6 + * // "next" 7 + * // "next" 8 + * // "next" 9 + * // "return" + * ``` */ export function mergeMap( project: (value: In, index: number) => Observable, @@ -18,7 +48,7 @@ export function mergeMap( return function mergeMapFn(source) { if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - source = pipe(source, asObservable()); + source = toObservable(source); return new Observable((observer) => { let index = 0; let outerSubscriptionHasReturned = false; @@ -28,7 +58,7 @@ export function mergeMap( signal: observer.signal, next(value) { activeInnerSubscriptions++; - pipe(project(value, index++), asObservable()).subscribe({ + project(value, index++).subscribe({ signal: observer.signal, next: (value) => observer.next(value), return() { diff --git a/merge/README.md b/merge/README.md new file mode 100644 index 0000000..b4f94f5 --- /dev/null +++ b/merge/README.md @@ -0,0 +1,52 @@ +# @observable/merge + +Creates and returns an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which +concurrently [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)s all values from every +given [source](https://jsr.io/@observable/core#source). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { merge } from "@observable/merge"; +import { Subject } from "@observable/core"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +const source1 = new Subject(); +const source2 = new Subject(); +const source3 = new Subject(); + +merge([source1, source2, source3]).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +source1.next(1); // "next" 1 +source2.next(2); // "next" 2 +source3.next(3); // "next" 3 +source1.return(); +source1.next(4); // "next" 4 +source2.return(); +source2.next(5); // "next" 5 +source3.return(); // "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/merge/deno.json b/merge/deno.json new file mode 100644 index 0000000..ae5fa2a --- /dev/null +++ b/merge/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/merge", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/merge.test.ts b/merge/mod.test.ts similarity index 68% rename from common/merge.test.ts rename to merge/mod.test.ts index 9fe4f6f..f14a1f5 100644 --- a/common/merge.test.ts +++ b/merge/mod.test.ts @@ -1,9 +1,8 @@ -import { merge } from "./merge.ts"; -import { of } from "./of.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { Observer } from "@xan/observable-core"; -import { pipe } from "./pipe.ts"; -import { materialize } from "./materialize.ts"; +import { merge } from "./mod.ts"; +import { of } from "@observable/of"; +import { Observer } from "@observable/core"; +import { pipe } from "@observable/pipe"; +import { materialize, type ObserverNotification } from "@observable/materialize"; import { assertEquals } from "@std/assert"; Deno.test("merge should merge the values", () => { diff --git a/merge/mod.ts b/merge/mod.ts new file mode 100644 index 0000000..96420ab --- /dev/null +++ b/merge/mod.ts @@ -0,0 +1,53 @@ +import type { Observable } from "@observable/core"; +import { + identity, + isIterable, + MinimumArgumentsRequiredError, + ParameterTypeError, +} from "@observable/internal"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { mergeMap } from "@observable/merge-map"; + +/** + * Creates and returns an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which concurrently + * [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)s all values from every given + * [source](https://jsr.io/@observable/core#source). + * @example + * ```ts + * import { merge } from "@observable/merge"; + * import { Subject } from "@observable/core"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * + * const controller = new AbortController(); + * const source1 = new Subject(); + * const source2 = new Subject(); + * const source3 = new Subject(); + * + * merge([source1, source2, source3]).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * source1.next(1); // "next" 1 + * source2.next(2); // "next" 2 + * source3.next(3); // "next" 3 + * source1.return(); + * source1.next(4); // "next" 4 + * source2.return(); + * source2.next(5); // "next" 5 + * source3.return(); // "return" + * ``` + */ +export function merge( + // Accepting an Iterable is a design choice for performance (iterables are lazily evaluated) and + // flexibility (can accept any iterable, not just arrays). + sources: Iterable>, +): Observable { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isIterable(sources)) throw new ParameterTypeError(0, "Iterable"); + return pipe(of(sources), mergeMap(identity)); +} diff --git a/never/README.md b/never/README.md new file mode 100644 index 0000000..28a2ee8 --- /dev/null +++ b/never/README.md @@ -0,0 +1,36 @@ +# @observable/never + +An [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that does nothing on +[`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { never } from "@observable/never"; + +const controller = new AbortController(); + +never.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), // Never called + return: () => console.log("return"), // Never called + throw: (value) => console.log("throw", value), // Never called +}); +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/never/deno.json b/never/deno.json new file mode 100644 index 0000000..ea1ee53 --- /dev/null +++ b/never/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/never", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/never.test.ts b/never/mod.test.ts similarity index 80% rename from common/never.test.ts rename to never/mod.test.ts index dfcf982..41a97f1 100644 --- a/common/never.test.ts +++ b/never/mod.test.ts @@ -1,9 +1,8 @@ import { assertEquals } from "@std/assert"; -import { Observer } from "@xan/observable-core"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { never } from "./never.ts"; -import { pipe } from "./pipe.ts"; +import { Observer } from "@observable/core"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { never } from "./mod.ts"; +import { pipe } from "@observable/pipe"; Deno.test( "never should not emit when subscribed to with an aborted signal", diff --git a/never/mod.ts b/never/mod.ts new file mode 100644 index 0000000..d0d5c3f --- /dev/null +++ b/never/mod.ts @@ -0,0 +1,21 @@ +import { Observable } from "@observable/core"; +import { noop } from "@observable/internal"; + +/** + * An [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that does nothing on + * [`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + * @example + * ```ts + * import { never } from "@observable/never"; + * + * const controller = new AbortController(); + * + * never.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), // Never called + * return: () => console.log("return"), // Never called + * throw: (value) => console.log("throw", value), // Never called + * }); + * ``` + */ +export const never: Observable = new Observable(noop); diff --git a/of/README.md b/of/README.md new file mode 100644 index 0000000..d9bbed8 --- /dev/null +++ b/of/README.md @@ -0,0 +1,67 @@ +# @observable/of + +Creates and returns an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that emits a +sequence of values in order on +[`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe) and then +[`return`](https://jsr.io/@observable/core/doc/~/Observer.return)s. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { of } from "@observable/of"; + +const controller = new AbortController(); + +of([1, 2, 3]).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.error("throw", value), +}); + +// Console output: +// "next" 1 +// "next" 2 +// "next" 3 +// "return" +``` + +## Example with early unsubscription + +```ts +import { of } from "@observable/of"; + +let count = 0; +const controller = new AbortController(); + +of([1, 2, 3]).subscribe({ + signal: controller.signal, + next(value) { + console.log("next", value); + if (value === 2) controller.abort(); + }, + return: () => console.log("return"), + throw: (value) => console.error("throw", value), +}); + +// Console output: +// "next" 1 +// "next" 2 +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/of/deno.json b/of/deno.json new file mode 100644 index 0000000..90c8263 --- /dev/null +++ b/of/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/of", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/of.test.ts b/of/mod.test.ts similarity index 78% rename from common/of.test.ts rename to of/mod.test.ts index d198543..e6ef89f 100644 --- a/common/of.test.ts +++ b/of/mod.test.ts @@ -1,9 +1,8 @@ import { assertEquals } from "@std/assert"; -import { Observer } from "@xan/observable-core"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { pipe } from "./pipe.ts"; -import { of } from "./of.ts"; +import { Observer } from "@observable/core"; +import { of } from "./mod.ts"; +import { pipe } from "@observable/pipe"; +import { materialize, type ObserverNotification } from "@observable/materialize"; Deno.test("of should return empty if no values are provided", () => { // Arrange diff --git a/common/of.ts b/of/mod.ts similarity index 64% rename from common/of.ts rename to of/mod.ts index b2146b4..fe6e518 100644 --- a/common/of.ts +++ b/of/mod.ts @@ -1,35 +1,35 @@ -import { Observable } from "@xan/observable-core"; +import { Observable } from "@observable/core"; import { isIterable, MinimumArgumentsRequiredError, ParameterTypeError, -} from "@xan/observable-internal"; +} from "@observable/internal"; /** - * Creates and returns an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) that emits a sequence of {@linkcode values} in order on - * [`subscribe`](https://jsr.io/@xan/observable-core/doc/~/Observable.subscribe) and then [`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return)s. + * Creates and returns an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that emits a sequence of {@linkcode values} in order on + * [`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe) and then [`return`](https://jsr.io/@observable/core/doc/~/Observer.return)s. * @example * ```ts - * import { of } from "@xan/observable-common"; + * import { of } from "@observable/of"; * * const controller = new AbortController(); * * of([1, 2, 3]).subscribe({ * signal: controller.signal, - * next: (value) => console.log(value), + * next: (value) => console.log("next", value), * return: () => console.log("return"), * throw: (value) => console.error("throw", value), * }); * - * // console output: - * // 1 - * // 2 - * // 3 - * // return + * // Console output: + * // "next" 1 + * // "next" 2 + * // "next" 3 + * // "return" * ``` * @example * ```ts - * import { of } from "@xan/observable-common"; + * import { of } from "@observable/of"; * * let count = 0; * const controller = new AbortController(); @@ -37,16 +37,16 @@ import { * of([1, 2, 3]).subscribe({ * signal: controller.signal, * next(value) { + * console.log("next", value); * if (value === 2) controller.abort(); - * console.log(value); * }, * return: () => console.log("return"), * throw: (value) => console.error("throw", value), * }); * - * // console output: - * // 1 - * // 2 + * // Console output: + * // "next" 1 + * // "next" 2 * ``` */ export function of( diff --git a/pairwise/README.md b/pairwise/README.md new file mode 100644 index 0000000..d224cf6 --- /dev/null +++ b/pairwise/README.md @@ -0,0 +1,43 @@ +# @observable/pairwise + +Emits pairs of consecutive values from the [source](https://jsr.io/@observable/core#source) +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { pairwise } from "@observable/pairwise"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe(of([1, 2, 3, 4]), pairwise()).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" [1, 2] +// "next" [2, 3] +// "next" [3, 4] +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/pairwise/deno.json b/pairwise/deno.json new file mode 100644 index 0000000..f3c6ff8 --- /dev/null +++ b/pairwise/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/pairwise", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/pairwise/mod.test.ts b/pairwise/mod.test.ts new file mode 100644 index 0000000..58a56f5 --- /dev/null +++ b/pairwise/mod.test.ts @@ -0,0 +1,216 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { flat } from "@observable/flat"; +import { Observable, Observer, Subject } from "@observable/core"; +import { throwError } from "@observable/throw-error"; +import { pipe } from "@observable/pipe"; +import { of } from "@observable/of"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { pairwise } from "./mod.ts"; + +Deno.test("pairwise should emit pairs of consecutive values", () => { + // Arrange + const notifications: Array> = []; + const source = of([1, 2, 3, 4, 5]); + const materialized = pipe(source, pairwise(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", [1, 2]], + ["next", [2, 3]], + ["next", [3, 4]], + ["next", [4, 5]], + ["return"], + ]); +}); + +Deno.test("pairwise should not emit if source emits only one value", () => { + // Arrange + const notifications: Array> = []; + const source = of([1]); + const materialized = pipe(source, pairwise(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["return"]]); +}); + +Deno.test("pairwise should not emit if source is empty", () => { + // Arrange + const notifications: Array> = []; + const source = of([]); + const materialized = pipe(source, pairwise(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["return"]]); +}); + +Deno.test("pairwise should emit exactly one pair when source emits two values", () => { + // Arrange + const notifications: Array> = []; + const source = of(["a", "b"]); + const materialized = pipe(source, pairwise(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["next", ["a", "b"]], ["return"]]); +}); + +Deno.test("pairwise should pump throws right through itself", () => { + // Arrange + const error = new Error("test error"); + const notifications: Array> = []; + const source = flat([of([1, 2, 3]), throwError(error)]); + const materialized = pipe(source, pairwise(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", [1, 2]], + ["next", [2, 3]], + ["throw", error], + ]); +}); + +Deno.test("pairwise should honor unsubscribe", () => { + // Arrange + const controller = new AbortController(); + const notifications: Array> = []; + const source = flat([of([1, 2, 3, 4, 5]), throwError(new Error("Should not make it here"))]); + const materialized = pipe(source, pairwise(), materialize()); + + // Act + materialized.subscribe( + new Observer({ + signal: controller.signal, + next: (notification) => { + notifications.push(notification); + if (notification[0] === "next" && notification[1][1] === 3) { + controller.abort(); + } + }, + }), + ); + + // Assert + assertEquals(notifications, [ + ["next", [1, 2]], + ["next", [2, 3]], + ]); +}); + +Deno.test("pairwise should throw when called with no source", () => { + // Arrange + const operator = pairwise(); + + // Act / Assert + assertThrows( + () => operator(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + +Deno.test("pairwise should throw when source is not an Observable", () => { + // Arrange + const operator = pairwise(); + + // Act / Assert + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(1 as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); +}); + +Deno.test("pairwise should work with Subject", () => { + // Arrange + const notifications: Array> = []; + const source = new Subject(); + const materialized = pipe(source, pairwise(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + source.next(10); + source.next(20); + source.next(30); + source.return(); + + // Assert + assertEquals(notifications, [ + ["next", [10, 20]], + ["next", [20, 30]], + ["return"], + ]); +}); + +Deno.test("pairwise should reset state for each subscription", () => { + // Arrange + const notifications1: Array> = []; + const notifications2: Array> = []; + const source = of([1, 2, 3]); + const pairwiseSource = pipe(source, pairwise()); + + // Act + pipe(pairwiseSource, materialize()).subscribe( + new Observer((notification) => notifications1.push(notification)), + ); + pipe(pairwiseSource, materialize()).subscribe( + new Observer((notification) => notifications2.push(notification)), + ); + + // Assert + assertEquals(notifications1, [ + ["next", [1, 2]], + ["next", [2, 3]], + ["return"], + ]); + assertEquals(notifications2, [ + ["next", [1, 2]], + ["next", [2, 3]], + ["return"], + ]); +}); + +Deno.test("pairwise should work with different types", () => { + // Arrange + const notifications: Array> = []; + const source = of(["first", "second", "third"]); + const materialized = pipe(source, pairwise(), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", ["first", "second"]], + ["next", ["second", "third"]], + ["return"], + ]); +}); diff --git a/pairwise/mod.ts b/pairwise/mod.ts new file mode 100644 index 0000000..dea160c --- /dev/null +++ b/pairwise/mod.ts @@ -0,0 +1,62 @@ +import { isObservable, type Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; +import { defer } from "@observable/defer"; +import { pipe } from "@observable/pipe"; +import { map } from "@observable/map"; +import { filter } from "@observable/filter"; + +/** + * Flag indicating that no value has been emitted yet. + * @internal Do NOT export. + */ +const noValue = Symbol("Flag indicating that no value has been emitted yet"); + +/** + * Emits pairs of consecutive values from the [source](https://jsr.io/@observable/core#source) + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable). + * @example + * ```ts + * import { pairwise } from "@observable/pairwise"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * + * const controller = new AbortController(); + * pipe(of([1, 2, 3, 4]), pairwise()).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "next" [1, 2] + * // "next" [2, 3] + * // "next" [3, 4] + * // "return" + * ``` + */ +export function pairwise(): ( + source: Observable, +) => Observable> { + return function pairwiseFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + source = toObservable(source); + return defer(() => { + let previous: Value | typeof noValue = noValue; + return pipe( + source, + filter((current) => { + const isFirst = previous === noValue; + if (isFirst) previous = current; + return !isFirst; + }), + map((current) => { + const pair = [previous, current] as const; + previous = current; + return pair; + }), + ); + }); + }; +} diff --git a/pipe/README.md b/pipe/README.md new file mode 100644 index 0000000..f80fd55 --- /dev/null +++ b/pipe/README.md @@ -0,0 +1,47 @@ +# @observable/pipe + +Pipe a value through a series of unary functions. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { pipe } from "@observable/pipe"; +import { of } from "@observable/of"; +import { map } from "@observable/map"; +import { filter } from "@observable/filter"; + +const controller = new AbortController(); + +pipe( + of([1, 2, 3, 4, 5]), + filter((value) => value % 2 === 0), + map((value) => value * 2), +).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 4 +// "next" 8 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/pipe/deno.json b/pipe/deno.json new file mode 100644 index 0000000..acfccb5 --- /dev/null +++ b/pipe/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/pipe", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/pipe.test.ts b/pipe/mod.test.ts similarity index 87% rename from common/pipe.test.ts rename to pipe/mod.test.ts index dcd61ab..e33a4e5 100644 --- a/common/pipe.test.ts +++ b/pipe/mod.test.ts @@ -1,4 +1,4 @@ -import { pipe } from "./pipe.ts"; +import { pipe } from "./mod.ts"; import { assertStrictEquals } from "@std/assert"; Deno.test("pipe should allow any kind of custom piping", () => { diff --git a/common/pipe.ts b/pipe/mod.ts similarity index 99% rename from common/pipe.ts rename to pipe/mod.ts index e0a3941..ad45f42 100644 --- a/common/pipe.ts +++ b/pipe/mod.ts @@ -1,4 +1,4 @@ -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; /** * A unary function that takes a {@linkcode source|value} and returns it. diff --git a/race/README.md b/race/README.md new file mode 100644 index 0000000..84289f8 --- /dev/null +++ b/race/README.md @@ -0,0 +1,51 @@ +# @observable/race + +Creates and returns an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that mirrors +the first [source](https://jsr.io/@observable/core#source) to +[`next`](https://jsr.io/@observable/core/doc/~/Observer.next) or +[`throw`](https://jsr.io/@observable/core/doc/~/Observer.throw) a value. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { race } from "@observable/race"; +import { Subject } from "@observable/core"; + +const controller = new AbortController(); +const source1 = new Subject(); +const source2 = new Subject(); +const source3 = new Subject(); + +race([source1, source2, source3]).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +source2.next(1); // "next" 1 +source1.next(2); +source3.next(3); +source1.return(); +source2.next(4); // "next" 4 +source2.return(); +source3.next(5); +source2.return(); // "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/race/deno.json b/race/deno.json new file mode 100644 index 0000000..69e52d2 --- /dev/null +++ b/race/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/race", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/race.test.ts b/race/mod.test.ts similarity index 84% rename from common/race.test.ts rename to race/mod.test.ts index b32e7dc..12f1bc3 100644 --- a/common/race.test.ts +++ b/race/mod.test.ts @@ -1,15 +1,14 @@ import { assertEquals } from "@std/assert"; -import { Observer, Subject } from "@xan/observable-core"; -import { empty } from "./empty.ts"; -import { never } from "./never.ts"; -import { pipe } from "./pipe.ts"; -import { throwError } from "./throw-error.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { race } from "./race.ts"; -import { defer } from "./defer.ts"; -import { of } from "./of.ts"; -import { flat } from "./flat.ts"; +import { Observer, Subject } from "@observable/core"; +import { empty } from "@observable/empty"; +import { never } from "@observable/never"; +import { pipe } from "@observable/pipe"; +import { throwError } from "@observable/throw-error"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { race } from "./mod.ts"; +import { defer } from "@observable/defer"; +import { of } from "@observable/of"; +import { flat } from "@observable/flat"; Deno.test( "race should mirror the first source observable to emit an item and ignore the others", diff --git a/race/mod.ts b/race/mod.ts new file mode 100644 index 0000000..e24ad1a --- /dev/null +++ b/race/mod.ts @@ -0,0 +1,76 @@ +import { type Observable, Observer } from "@observable/core"; +import { + isIterable, + MinimumArgumentsRequiredError, + noop, + ParameterTypeError, +} from "@observable/internal"; +import { defer } from "@observable/defer"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { tap } from "@observable/tap"; +import { mergeMap } from "@observable/merge-map"; +import { takeUntil } from "@observable/take-until"; +import { filter } from "@observable/filter"; +import { AsyncSubject } from "@observable/async-subject"; + +/** + * Creates and returns an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that mirrors the first + * [source](https://jsr.io/@observable/core#source) to [`next`](https://jsr.io/@observable/core/doc/~/Observer.next) or + * [`throw`](https://jsr.io/@observable/core/doc/~/Observer.throw) a value. + * @example + * ```ts + * import { race } from "@observable/race"; + * import { Subject } from "@observable/core"; + * + * const controller = new AbortController(); + * const source1 = new Subject(); + * const source2 = new Subject(); + * const source3 = new Subject(); + * + * race([source1, source2, source3]).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * source2.next(1); // "next" 1 + * source1.next(2); + * source3.next(3); + * source1.return(); + * source2.next(4); // "next" 4 + * source2.return(); + * source3.next(5); + * source2.return(); // "return" + * ``` + */ +export function race( + // Accepting an Iterable is a design choice for performance (iterables are lazily evaluated) and + // flexibility (can accept any iterable, not just arrays). + sources: Iterable>, +): Observable { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isIterable(sources)) throw new ParameterTypeError(0, "Iterable"); + return defer(() => { + const finished = new AsyncSubject(); + return pipe( + of(sources), + takeUntil(finished), + mergeMap((source, index) => { + const observer = new Observer({ next: finish, throw: noop }); + const lost = pipe(finished, filter(isLoser)); + return pipe(source, tap(observer), takeUntil(lost)); + + function finish(): void { + finished.next(index); + finished.return(); + } + + function isLoser(winnerIndex: number): boolean { + return winnerIndex !== index; + } + }), + ); + }); +} diff --git a/replay-subject/README.md b/replay-subject/README.md new file mode 100644 index 0000000..2e19644 --- /dev/null +++ b/replay-subject/README.md @@ -0,0 +1,67 @@ +# @observable/replay-subject + +A variant of [`Subject`](https://jsr.io/@observable/core/doc/~/Subject) that replays buffered +[`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values upon +[`subscription`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { ReplaySubject } from "@observable/replay-subject"; + +const subject = new ReplaySubject(3); +const controller = new AbortController(); + +subject.next(1); // Stored in buffer +subject.next(2); // Stored in buffer +subject.next(3); // Stored in buffer +subject.next(4); // Stored in buffer and 1 gets trimmed off + +subject.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 2 +// "next" 3 +// "next" 4 + +// Values pushed after the subscribe will emit immediately +// unless the subject is already finalized. +subject.next(5); // Stored in buffer and 2 gets trimmed off + +// Console output: +// "next" 5 + +subject.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 3 +// "next" 4 +// "next" 5 +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/replay-subject/deno.json b/replay-subject/deno.json new file mode 100644 index 0000000..423f66f --- /dev/null +++ b/replay-subject/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/replay-subject", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/replay-subject.test.ts b/replay-subject/mod.test.ts similarity index 95% rename from common/replay-subject.test.ts rename to replay-subject/mod.test.ts index 6b516b7..59ddad4 100644 --- a/common/replay-subject.test.ts +++ b/replay-subject/mod.test.ts @@ -1,11 +1,10 @@ import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; -import { Observer } from "@xan/observable-core"; -import { noop } from "@xan/observable-internal"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { ReplaySubject } from "./replay-subject.ts"; +import { Observer } from "@observable/core"; +import { noop } from "@observable/internal"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { ReplaySubject } from "./mod.ts"; Deno.test("ReplaySubject.toString should be '[object ReplaySubject]'", () => { // Arrange / Act / Assert diff --git a/common/replay-subject.ts b/replay-subject/mod.ts similarity index 54% rename from common/replay-subject.ts rename to replay-subject/mod.ts index e856994..4f6b6fb 100644 --- a/common/replay-subject.ts +++ b/replay-subject/mod.ts @@ -1,19 +1,74 @@ -import { isObserver, type Observable, type Observer, Subject } from "@xan/observable-core"; +import { isObserver, type Observable, type Observer, Subject } from "@observable/core"; import { InstanceofError, MinimumArgumentsRequiredError, ParameterTypeError, -} from "@xan/observable-internal"; -import { flat } from "./flat.ts"; -import { defer } from "./defer.ts"; -import { of } from "./of.ts"; -import type { ReplaySubjectConstructor } from "./replay-subject-constructor.ts"; +} from "@observable/internal"; +import { flat } from "@observable/flat"; +import { defer } from "@observable/defer"; +import { of } from "@observable/of"; /** - * Object type that acts as a variant of [`Subject`](https://jsr.io/@xan/observable-core/doc/~/Subject). + * Object type that acts as a variant of [`Subject`](https://jsr.io/@observable/core/doc/~/Subject). */ export type ReplaySubject = Subject; +/** + * Object interface for an {@linkcode ReplaySubject} factory. + */ +export interface ReplaySubjectConstructor { + /** + * Creates and returns an object that acts as a [`Subject`](https://jsr.io/@observable/core/doc/~/Subject) that replays + * buffered [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values upon + * [`subscription`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + * @example + * ```ts + * import { ReplaySubject } from "@observable/replay-subject"; + * + * const subject = new ReplaySubject(3); + * const controller = new AbortController(); + * + * subject.next(1); // Stored in buffer + * subject.next(2); // Stored in buffer + * subject.next(3); // Stored in buffer + * subject.next(4); // Stored in buffer and 1 gets trimmed off + * + * subject.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "next" 2 + * // "next" 3 + * // "next" 4 + * + * // Values pushed after the subscribe will emit immediately + * // unless the subject is already finalized. + * subject.next(5); // Stored in buffer and 2 gets trimmed off + * + * // Console output: + * // "next" 5 + * + * subject.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output: + * // "next" 3 + * // "next" 4 + * // "next" 5 + * ``` + */ + new (bufferSize: number): ReplaySubject; + readonly prototype: ReplaySubject; +} + export const ReplaySubject: ReplaySubjectConstructor = class { readonly [Symbol.toStringTag] = "ReplaySubject"; readonly #bufferSize: number; diff --git a/share/README.md b/share/README.md new file mode 100644 index 0000000..2b66138 --- /dev/null +++ b/share/README.md @@ -0,0 +1,52 @@ +# @observable/share + +Converts an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) to an +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that shares a single subscription +to the [source](https://jsr.io/@observable/core#source) +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { share } from "@observable/share"; +import { timer } from "@observable/timer"; +import { pipe } from "@observable/pipe"; + +const shared = pipe(timer(1_000), share()); +const controller = new AbortController(); +shared.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); +shared.subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output (after 1 second): +// "next" 0 +// "next" 0 +// "return" +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/share/deno.json b/share/deno.json new file mode 100644 index 0000000..f031cc4 --- /dev/null +++ b/share/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/share", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/share/mod.test.ts b/share/mod.test.ts new file mode 100644 index 0000000..ef58407 --- /dev/null +++ b/share/mod.test.ts @@ -0,0 +1,456 @@ +import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; +import { Observer, Subject } from "@observable/core"; +import { pipe } from "@observable/pipe"; +import { share } from "./mod.ts"; +import { of } from "@observable/of"; +import { throwError } from "@observable/throw-error"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { ReplaySubject } from "@observable/replay-subject"; +import { defer } from "@observable/defer"; +import { never } from "@observable/never"; +import { flat } from "@observable/flat"; +import { take } from "@observable/take"; +import { ignoreElements } from "@observable/ignore-elements"; +import { empty } from "@observable/empty"; + +Deno.test("share should not throw when called with no connector argument", () => { + // Arrange / Act / Assert + share(...([] as unknown as Parameters)); +}); + +Deno.test("share should throw when connector is not a function", () => { + // Arrange / Act / Assert + assertThrows( + // deno-lint-ignore no-explicit-any + () => share(1 as any), + TypeError, + "Parameter 1 is not of type 'Function'", + ); + assertThrows( + // deno-lint-ignore no-explicit-any + () => share(null as any), + TypeError, + "Parameter 1 is not of type 'Function'", + ); + assertThrows( + // deno-lint-ignore no-explicit-any + () => share("test" as any), + TypeError, + "Parameter 1 is not of type 'Function'", + ); +}); + +Deno.test("share should throw when called with no source", () => { + // Arrange + const operator = share(); + + // Act / Assert + assertThrows( + () => operator(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + +Deno.test("share should throw when source is not an Observable", () => { + // Arrange + const operator = share(); + + // Act / Assert + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(1 as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(null as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(undefined as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); +}); + +Deno.test("share should multicast values to all subscribers", () => { + // Arrange + const source = new Subject(); + const shared = pipe(source, share()); + const notifications1: Array> = []; + const notifications2: Array> = []; + + // Act + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications1.push(notification)), + ); + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications2.push(notification)), + ); + source.next(1); + source.next(2); + source.next(3); + + // Assert + assertEquals(notifications1, [["next", 1], ["next", 2], ["next", 3]]); + assertEquals(notifications2, [["next", 1], ["next", 2], ["next", 3]]); +}); + +Deno.test("share should only subscribe to source once for multiple subscribers", () => { + // Arrange + let subscribeCount = 0; + const source = defer(() => { + subscribeCount++; + return never; + }); + const shared = pipe(source, share()); + + // Act + shared.subscribe(new Observer()); + shared.subscribe(new Observer()); + shared.subscribe(new Observer()); + + // Assert + assertStrictEquals(subscribeCount, 1); +}); + +Deno.test("share should not subscribe to source until first subscriber", () => { + // Arrange + let subscribed = false; + const source = defer(() => { + subscribed = true; + return of([1]); + }); + const shared = pipe(source, share()); + assertStrictEquals(subscribed, false); + + // Act + shared.subscribe(new Observer()); + + // Assert + assertStrictEquals(subscribed, true); +}); + +Deno.test("share should reset when all subscribers unsubscribe", () => { + // Arrange + let subscribeCount = 0; + const source = defer(() => { + subscribeCount++; + return never; + }); + const shared = pipe(source, share()); + const controller1 = new AbortController(); + const controller2 = new AbortController(); + + // Act + shared.subscribe(new Observer({ signal: controller1.signal })); + shared.subscribe(new Observer({ signal: controller2.signal })); + assertStrictEquals(subscribeCount, 1); + controller1.abort(); + assertStrictEquals(subscribeCount, 1); + controller2.abort(); + shared.subscribe(new Observer()); + + // Assert + assertStrictEquals(subscribeCount, 2); +}); + +Deno.test("share should propagate throw to all subscribers", () => { + // Arrange + const throwNotifier = new Subject(); + const error = new Error("test error"); + const source = flat([ + pipe(throwNotifier, take(1), ignoreElements()), + throwError(error), + ]); + const shared = pipe(source, share()); + const notifications1: Array> = []; + const notifications2: Array> = []; + + // Act + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications1.push(notification)), + ); + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications2.push(notification)), + ); + throwNotifier.next(); + + // Assert + assertEquals(notifications1, [["throw", error]]); + assertEquals(notifications2, [["throw", error]]); +}); + +Deno.test("share should propagate return to all subscribers", () => { + // Arrange + const returnNotifier = new Subject(); + const source = flat([ + pipe(returnNotifier, take(1), ignoreElements()), + empty, + ]); + const shared = pipe(source, share()); + const notifications1: Array> = []; + const notifications2: Array> = []; + + // Act + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications1.push(notification)), + ); + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications2.push(notification)), + ); + returnNotifier.next(); + + // Assert + assertEquals(notifications1, [["return"]]); + assertEquals(notifications2, [["return"]]); +}); + +Deno.test("share should use custom connector", () => { + // Arrange + let connectorCalled = false; + const customSubject = new Subject(); + const connector = () => { + connectorCalled = true; + return customSubject; + }; + const source = of([1, 2, 3]); + const shared = pipe(source, share(connector)); + + // Act + shared.subscribe(new Observer()); + + // Assert + assertStrictEquals(connectorCalled, true); +}); + +Deno.test("share with ReplaySubject should replay values to late subscribers", () => { + // Arrange + const source = new Subject(); + const shared = pipe(source, share(() => new ReplaySubject(2))); + const notifications1: Array> = []; + const notifications2: Array> = []; + + // Act + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications1.push(notification)), + ); + source.next(1); + source.next(2); + source.next(3); + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications2.push(notification)), + ); + source.next(4); + + // Assert + assertEquals(notifications1, [["next", 1], ["next", 2], ["next", 3], ["next", 4]]); + assertEquals(notifications2, [["next", 2], ["next", 3], ["next", 4]]); +}); + +Deno.test("share should reset connection when all unsubscribe", () => { + // Arrange + let subscribeCount = 0; + const source = defer(() => { + subscribeCount++; + return never; + }); + const shared = pipe(source, share()); + const controller1 = new AbortController(); + const controller2 = new AbortController(); + + // Act + shared.subscribe(new Observer({ signal: controller1.signal })); + shared.subscribe(new Observer({ signal: controller2.signal })); + assertStrictEquals(subscribeCount, 1); + controller1.abort(); + controller2.abort(); + shared.subscribe(new Observer()); + + // Assert + assertStrictEquals(subscribeCount, 2); +}); + +Deno.test("share should handle synchronous source return", () => { + // Arrange + const source = of([1, 2, 3]); + const shared = pipe(source, share()); + const notifications: Array> = []; + + // Act + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", 3], + ["return"], + ]); +}); + +Deno.test("share should handle synchronous source throw", () => { + // Arrange + const error = new Error("sync error"); + const source = throwError(error); + const shared = pipe(source, share()); + const notifications: Array> = []; + + // Act + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["throw", error]]); +}); + +Deno.test("share should create new subject after reset via unsubscribe", () => { + // Arrange + let connectionCount = 0; + const connector = () => { + connectionCount++; + return new Subject(); + }; + const source = never; + const shared = pipe(source, share(connector)); + const controller1 = new AbortController(); + + // Act + shared.subscribe(new Observer({ signal: controller1.signal })); + assertStrictEquals(connectionCount, 1); + controller1.abort(); + shared.subscribe(new Observer()); + + // Assert + assertStrictEquals(connectionCount, 2); +}); + +Deno.test("share should create new source subscription after source returns", () => { + // Arrange + let sourceSubscribeCount = 0; + const source = defer(() => { + sourceSubscribeCount++; + return of([sourceSubscribeCount]); + }); + const shared = pipe(source, share()); + const notifications1: Array> = []; + const notifications2: Array> = []; + + // Act + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications1.push(notification)), + ); + assertStrictEquals(sourceSubscribeCount, 1); + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications2.push(notification)), + ); + + // Assert + assertStrictEquals(sourceSubscribeCount, 2); + assertEquals(notifications1, [["next", 1], ["return"]]); + assertEquals(notifications2, [["next", 2], ["return"]]); +}); + +Deno.test("share should not create new subject for second subscriber", () => { + // Arrange + let connectionCount = 0; + const connector = () => { + connectionCount++; + return new Subject(); + }; + const source = never; + const shared = pipe(source, share(connector)); + + // Act + shared.subscribe(new Observer()); + shared.subscribe(new Observer()); + shared.subscribe(new Observer()); + + // Assert + assertStrictEquals(connectionCount, 1); +}); + +Deno.test("share should handle late subscriber joining during emission", () => { + // Arrange + const source = new Subject(); + const shared = pipe(source, share()); + const notifications1: Array> = []; + const notifications2: Array> = []; + + // Act + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications1.push(notification)), + ); + source.next(1); + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications2.push(notification)), + ); + source.next(2); + source.next(3); + + // Assert + assertEquals(notifications1, [["next", 1], ["next", 2], ["next", 3]]); + assertEquals(notifications2, [["next", 2], ["next", 3]]); +}); + +Deno.test("share should handle subscriber unsubscribing during emission", () => { + // Arrange + const source = new Subject(); + const shared = pipe(source, share()); + const notifications1: Array> = []; + const notifications2: Array> = []; + const controller1 = new AbortController(); + + // Act + pipe(shared, materialize()).subscribe( + new Observer({ + signal: controller1.signal, + next: (notification) => { + notifications1.push(notification); + if (notification[0] === "next" && notification[1] === 2) controller1.abort(); + }, + }), + ); + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications2.push(notification)), + ); + source.next(1); + source.next(2); + source.next(3); + + // Assert + assertEquals(notifications1, [["next", 1], ["next", 2]]); + assertEquals(notifications2, [["next", 1], ["next", 2], ["next", 3]]); +}); + +Deno.test("share should work with observable-like sources", () => { + // Arrange + const observableLike = { + subscribe(observer: Observer) { + observer.next(1); + observer.next(2); + observer.return(); + }, + }; + const shared = pipe(observableLike, share()); + const notifications: Array> = []; + + // Act + pipe(shared, materialize()).subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["return"], + ]); +}); diff --git a/share/mod.ts b/share/mod.ts new file mode 100644 index 0000000..ba68f5b --- /dev/null +++ b/share/mod.ts @@ -0,0 +1,63 @@ +import { isObservable, Observable, Subject, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; +import { pipe } from "@observable/pipe"; +import { finalize } from "@observable/finalize"; +import { defer } from "@observable/defer"; + +/** + * Converts an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) to a {@linkcode Observable} that shares + * a single subscription to the source [`Observable`](https://jsr.io/@observable/core/doc/~/Observable). + * @example + * ```ts + * import { share } from "@observable/share"; + * import { timer } from "@observable/timer"; + * import { pipe } from "@observable/pipe"; + * + * const shared = pipe(timer(1_000), share()); + * const controller = new AbortController(); + * shared.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * shared.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * // Console output (after 1 second): + * // "next" 0 + * // "next" 0 + * // "return" + * // "return" + * ``` + */ +export function share( + connector = () => new Subject>(), +): (source: Observable) => Observable { + if (typeof connector !== "function") { + throw new ParameterTypeError(0, "Function"); + } + return function shareFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + let activeSubscriptions = 0; + let connection: Observable | undefined; + source = toObservable(source); + return pipe( + defer(() => { + ++activeSubscriptions; + if (isObservable(connection)) return connection; + return new Observable((observer) => { + const subject = connector(); + (connection = toObservable(subject)).subscribe(observer); + source.subscribe(subject); + }); + }), + finalize(() => --activeSubscriptions === 0 && (connection = undefined)), + ); + }; +} diff --git a/switch-map/README.md b/switch-map/README.md new file mode 100644 index 0000000..e3de76d --- /dev/null +++ b/switch-map/README.md @@ -0,0 +1,51 @@ +# @observable/switch-map + +Projects each [source](https://jsr.io/@observable/core#source) value to an +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which is merged in the output +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable), emitting values only from the most +recently projected [`Observable`](https://jsr.io/@observable/core/doc/~/Observable). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { switchMap } from "@observable/switch-map"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +const observableLookup = { + 1: of([1, 2, 3]), + 2: of([4, 5, 6]), + 3: of([7, 8, 9]), +} as const; + +pipe(of([1, 2, 3]), switchMap((value) => observableLookup[value])).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 7 +// "next" 8 +// "next" 9 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/switch-map/deno.json b/switch-map/deno.json new file mode 100644 index 0000000..3f9fc45 --- /dev/null +++ b/switch-map/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/switch-map", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/switch-map.test.ts b/switch-map/mod.test.ts similarity index 96% rename from common/switch-map.test.ts rename to switch-map/mod.test.ts index f58148d..9f215e2 100644 --- a/common/switch-map.test.ts +++ b/switch-map/mod.test.ts @@ -1,17 +1,16 @@ import { assertEquals } from "@std/assert"; -import { Observable, Observer, Subject } from "@xan/observable-core"; -import { empty } from "./empty.ts"; -import { never } from "./never.ts"; -import { defer } from "./defer.ts"; -import { pipe } from "./pipe.ts"; -import { take } from "./take.ts"; -import { throwError } from "./throw-error.ts"; -import { BehaviorSubject } from "./behavior-subject.ts"; -import { flat } from "./flat.ts"; -import { switchMap } from "./switch-map.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { map } from "./map.ts"; +import { Observable, Observer, Subject } from "@observable/core"; +import { empty } from "@observable/empty"; +import { never } from "@observable/never"; +import { defer } from "@observable/defer"; +import { pipe } from "@observable/pipe"; +import { take } from "@observable/take"; +import { throwError } from "@observable/throw-error"; +import { BehaviorSubject } from "@observable/behavior-subject"; +import { flat } from "@observable/flat"; +import { switchMap } from "./mod.ts"; +import { map } from "@observable/map"; +import { materialize, type ObserverNotification } from "@observable/materialize"; Deno.test("switchMap should map-and-flatten each item to an Observable", () => { // Arrange diff --git a/switch-map/mod.ts b/switch-map/mod.ts new file mode 100644 index 0000000..b6e8e3a --- /dev/null +++ b/switch-map/mod.ts @@ -0,0 +1,54 @@ +import { isObservable, type Observable, Observer, Subject, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@observable/internal"; +import { defer } from "@observable/defer"; +import { pipe } from "@observable/pipe"; +import { takeUntil } from "@observable/take-until"; +import { tap } from "@observable/tap"; +import { mergeMap } from "@observable/merge-map"; + +/** + * {@linkcode project|Projects} each [source](https://jsr.io/@observable/core#source) value to an + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) which is merged in the output + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable), emitting values only from the most + * recently {@linkcode project|projected} [`Observable`](https://jsr.io/@observable/core/doc/~/Observable). + * @example + * ```ts + * import { BehaviorSubject } from "@observable/behavior-subject"; + * import { switchMap } from "@observable/switch-map"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; + * + * const page = new BehaviorSubject(1); + * const controller = new AbortController(); + * pipe(page, switchMap((value) => fetchPage(value))).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * function fetchPage(page: number): Observable { + * return of(`Page ${page}`); + * } + */ +export function switchMap( + project: (value: In, index: number) => Observable, +): (source: Observable) => Observable { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (typeof project !== "function") { + throw new ParameterTypeError(0, "Function"); + } + return function switchMapFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + source = toObservable(source); + return defer(() => { + const switching = new Subject(); + return pipe( + source, + tap(new Observer({ next: () => switching.next(), throw: noop })), + mergeMap((value, index) => pipe(project(value, index), takeUntil(switching))), + ); + }); + }; +} diff --git a/take-until/README.md b/take-until/README.md new file mode 100644 index 0000000..4ff9b33 --- /dev/null +++ b/take-until/README.md @@ -0,0 +1,48 @@ +# @observable/take-until + +Takes [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values from the +[source](https://jsr.io/@observable/core#source) until +[notified](https://jsr.io/@observable/core#notifier) to stop. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { Subject } from "@observable/core"; +import { takeUntil } from "@observable/take-until"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +const source = new Subject(); +const notifier = new Subject(); + +pipe(source, takeUntil(notifier)).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +source.next(1); // "next" 1 +source.next(2); // "next" 2 +notifier.next(); // "return" +source.next(3); +source.return(); +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/take-until/deno.json b/take-until/deno.json new file mode 100644 index 0000000..7e4a486 --- /dev/null +++ b/take-until/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/take-until", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/take-until.test.ts b/take-until/mod.test.ts similarity index 91% rename from common/take-until.test.ts rename to take-until/mod.test.ts index 41a38d9..335c353 100644 --- a/common/take-until.test.ts +++ b/take-until/mod.test.ts @@ -1,11 +1,10 @@ import { assertEquals } from "@std/assert"; -import { Observable, Observer, Subject } from "@xan/observable-core"; -import { noop } from "@xan/observable-internal"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { takeUntil } from "./take-until.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; +import { Observable, Observer, Subject } from "@observable/core"; +import { noop } from "@observable/internal"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { takeUntil } from "./mod.ts"; +import { materialize, type ObserverNotification } from "@observable/materialize"; Deno.test("takeUntil should return when notifier nexts", () => { // Arrange diff --git a/common/take-until.ts b/take-until/mod.ts similarity index 58% rename from common/take-until.ts rename to take-until/mod.ts index 6743157..6c29cd4 100644 --- a/common/take-until.ts +++ b/take-until/mod.ts @@ -1,16 +1,16 @@ -import { isObservable, Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@xan/observable-internal"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; +import { isObservable, Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, noop, ParameterTypeError } from "@observable/internal"; /** - * Takes [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed values from the - * [source](https://jsr.io/@xan/observable-core#source) until [notified](https://jsr.io/@xan/observable-core#notifier) + * Takes [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values from the + * [source](https://jsr.io/@observable/core#source) until [notified](https://jsr.io/@observable/core#notifier) * to not. * @example * ```ts - * import { Subject } from "@xan/observable-core"; - * import { takeUntil, of, pipe } from "@xan/observable-common"; + * import { Subject } from "@observable/core"; + * import { takeUntil } from "@observable/take-until"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; * * const controller = new AbortController(); * const source = new Subject(); @@ -18,21 +18,16 @@ import { asObservable } from "./as-observable.ts"; * * pipe(source, takeUntil(notifier)).subscribe({ * signal: controller.signal, - * next: (value) => console.log(value), + * next: (value) => console.log("next", value), * return: () => console.log("return"), - * throw: (value) => console.log(value), + * throw: (value) => console.log("throw", value), * }); * - * source.next(1); - * source.next(2); - * notifier.next(); + * source.next(1); // "next" 1 + * source.next(2); // "next" 2 + * notifier.next(); // "return" * source.next(3); * source.return(); - * - * // console output: - * // 1 - * // 2 - * // return * ``` */ export function takeUntil( @@ -40,11 +35,11 @@ export function takeUntil( ): (source: Observable) => Observable { if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); if (!isObservable(notifier)) throw new ParameterTypeError(0, "Observable"); - notifier = pipe(notifier, asObservable()); + notifier = toObservable(notifier); return function takeUntilFn(source) { if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - source = pipe(source, asObservable()); + source = toObservable(source); return new Observable((observer) => { notifier.subscribe({ signal: observer.signal, diff --git a/take/README.md b/take/README.md new file mode 100644 index 0000000..7926bb8 --- /dev/null +++ b/take/README.md @@ -0,0 +1,42 @@ +# @observable/take + +Takes the first `count` values [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed by +the [source](https://jsr.io/@observable/core#source). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { take } from "@observable/take"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +pipe(of([1, 2, 3, 4, 5]), take(2)).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "next" 1 +// "next" 2 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/take/deno.json b/take/deno.json new file mode 100644 index 0000000..359a3bc --- /dev/null +++ b/take/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/take", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/take.test.ts b/take/mod.test.ts similarity index 84% rename from common/take.test.ts rename to take/mod.test.ts index 67b5c2f..66bf4d1 100644 --- a/common/take.test.ts +++ b/take/mod.test.ts @@ -1,13 +1,12 @@ import { assertEquals, assertStrictEquals } from "@std/assert"; -import { Observable, Observer, Subject } from "@xan/observable-core"; -import { noop } from "@xan/observable-internal"; -import { empty } from "./empty.ts"; -import { never } from "./never.ts"; -import { of } from "./of.ts"; -import { pipe } from "./pipe.ts"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { take } from "./take.ts"; +import { Observable, Observer, Subject } from "@observable/core"; +import { noop } from "@observable/internal"; +import { empty } from "@observable/empty"; +import { never } from "@observable/never"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { take } from "./mod.ts"; Deno.test( "take should return an empty observable if the count is equal to 0", diff --git a/common/take.ts b/take/mod.ts similarity index 65% rename from common/take.ts rename to take/mod.ts index 0ede1b9..af6adb4 100644 --- a/common/take.ts +++ b/take/mod.ts @@ -1,28 +1,28 @@ -import { isObservable, Observable } from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import { empty } from "./empty.ts"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; +import { isObservable, Observable, toObservable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; +import { empty } from "@observable/empty"; /** - * Takes the first {@linkcode count} values [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed - * by the [source](https://jsr.io/@xan/observable-core#source). + * Takes the first {@linkcode count} values [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed + * by the [source](https://jsr.io/@observable/core#source). * @example * ```ts - * import { take, of, pipe } from "@xan/observable-common"; + * import { take } from "@observable/take"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; * * const controller = new AbortController(); * pipe(of([1, 2, 3, 4, 5]), take(2)).subscribe({ * signal: controller.signal, - * next: (value) => console.log(value), + * next: (value) => console.log("next", value), * return: () => console.log("return"), - * throw: (value) => console.log(value), + * throw: (value) => console.log("throw", value), * }); * - * // console output: - * // 1 - * // 2 - * // return + * // Console output: + * // "next" 1 + * // "next" 2 + * // "return" * ``` */ export function take( @@ -32,7 +32,7 @@ export function take( if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); if (count <= 0 || Number.isNaN(count)) return empty; - source = pipe(source, asObservable()); + source = toObservable(source); if (count === Infinity) return source; return new Observable((observer) => { let seen = 0; diff --git a/tap/README.md b/tap/README.md new file mode 100644 index 0000000..dbb9c94 --- /dev/null +++ b/tap/README.md @@ -0,0 +1,57 @@ +# @observable/tap + +Used to perform side-effects on the [source](https://jsr.io/@observable/core#source). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { tap } from "@observable/tap"; +import { of } from "@observable/of"; +import { pipe } from "@observable/pipe"; + +const subscriptionController = new AbortController(); +const tapController = new AbortController(); + +pipe( + of([1, 2, 3]), + tap({ + signal: tapController.signal, + next(value) { + if (value === 2) controller.abort(); + console.log("tap next", value); + }, + return: () => console.log("tap return"), + throw: (value) => console.log("tap throw", value), + }), +).subscribe({ + signal: subscriptionController.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output: +// "tap next" 1 +// "next" 1 +// "tap next" 2 +// "next" 2 +// "next" 3 +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/tap/deno.json b/tap/deno.json new file mode 100644 index 0000000..a0c3a7e --- /dev/null +++ b/tap/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/tap", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/tap.test.ts b/tap/mod.test.ts similarity index 90% rename from common/tap.test.ts rename to tap/mod.test.ts index 8b06b7b..0a5139b 100644 --- a/common/tap.test.ts +++ b/tap/mod.test.ts @@ -1,12 +1,11 @@ import { assertEquals } from "@std/assert"; -import { Observable, Observer } from "@xan/observable-core"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { throwError } from "./throw-error.ts"; -import { pipe } from "./pipe.ts"; -import { tap } from "./tap.ts"; -import { empty } from "./empty.ts"; -import { of } from "./of.ts"; +import { Observable, Observer } from "@observable/core"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { throwError } from "@observable/throw-error"; +import { pipe } from "@observable/pipe"; +import { tap } from "./mod.ts"; +import { empty } from "@observable/empty"; +import { of } from "@observable/of"; Deno.test("tap should pump next notifications to the provided observer", () => { // Arrange diff --git a/common/tap.ts b/tap/mod.ts similarity index 80% rename from common/tap.ts rename to tap/mod.ts index 65bbaad..483b6a9 100644 --- a/common/tap.ts +++ b/tap/mod.ts @@ -3,16 +3,18 @@ import { isObserver, Observable, type Observer, + toObservable, toObserver, -} from "@xan/observable-core"; -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import { pipe } from "./pipe.ts"; -import { asObservable } from "./as-observable.ts"; +} from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; /** - * Used to perform side-effects on the [source](https://jsr.io/@xan/observable-core#source). + * Used to perform side-effects on the [source](https://jsr.io/@observable/core#source). + * @example * ```ts - * import { of, pipe, tap } from "@xan/observable-common"; + * import { tap } from "@observable/tap"; + * import { of } from "@observable/of"; + * import { pipe } from "@observable/pipe"; * * const subscriptionController = new AbortController(); * const tapController = new AbortController(); @@ -30,17 +32,17 @@ import { asObservable } from "./as-observable.ts"; * }) * ).subscribe({ * signal: subscriptionController.signal, - * next: (value) => console.log(value), + * next: (value) => console.log("next", value), * return: () => console.log("return"), * throw: (value) => console.log("throw", value), * }); * - * // console output: - * // tap next 1 - * // 1 - * // tap next 2 - * // 2 - * // 3 + * // Console output: + * // "tap next" 1 + * // "next" 1 + * // "tap next" 2 + * // "next" 2 + * // "next" 3 * // "return" * ``` */ @@ -53,7 +55,7 @@ export function tap( return function tapFn(source) { if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); - source = pipe(source, asObservable()); + source = toObservable(source); return new Observable((observer) => source.subscribe({ signal: observer.signal, diff --git a/throttle/README.md b/throttle/README.md new file mode 100644 index 0000000..b027507 --- /dev/null +++ b/throttle/README.md @@ -0,0 +1,52 @@ +# @observable/throttle + +Throttles the emission of values from the [source](https://jsr.io/@observable/core#source) +[`Observable`](https://jsr.io/@observable/core/doc/~/Observable) by the specified number of +milliseconds. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { throttle } from "@observable/throttle"; +import { Subject } from "@observable/core"; +import { pipe } from "@observable/pipe"; + +const controller = new AbortController(); +const source = new Subject(); + +pipe(source, throttle(100)).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +source.next(1); // Emitted immediately +source.next(2); // Ignored (within throttle window) +source.next(3); // Ignored (within throttle window) + +// After 100ms, the next value will be emitted +source.next(4); // Emitted after throttle window + +// Console output: +// "next" 1 +// (after 100ms) +// "next" 4 +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/throttle/deno.json b/throttle/deno.json new file mode 100644 index 0000000..e568515 --- /dev/null +++ b/throttle/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/throttle", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/throttle/mod.test.ts b/throttle/mod.test.ts new file mode 100644 index 0000000..d95a4b3 --- /dev/null +++ b/throttle/mod.test.ts @@ -0,0 +1,244 @@ +import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; +import { Observable, Observer, Subject } from "@observable/core"; +import { empty } from "@observable/empty"; +import { pipe } from "@observable/pipe"; +import { of } from "@observable/of"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { throttle } from "./mod.ts"; + +Deno.test("throttle should return empty if milliseconds is negative", () => { + // Arrange + const source = of([1, 2, 3]); + + // Act + const result = pipe(source, throttle(-1)); + + // Assert + assertStrictEquals(result, empty); +}); + +Deno.test("throttle should return empty if milliseconds is NaN", () => { + // Arrange + const source = of([1, 2, 3]); + + // Act + const result = pipe(source, throttle(NaN)); + + // Assert + assertStrictEquals(result, empty); +}); + +Deno.test("throttle should emit first value immediately", () => { + // Arrange + let overrideGlobals = true; + const notifications: Array> = []; + const setTimeoutCalls: Array> = []; + const originalSetTimeout = globalThis.setTimeout; + Object.defineProperty(globalThis, "setTimeout", { + value: (...args: Parameters) => { + setTimeoutCalls.push(args); + return overrideGlobals ? Math.random() : originalSetTimeout(...args); + }, + }); + + const source = new Subject(); + const materialized = pipe(source, throttle(100), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + source.next(1); + + // Assert + assertEquals(notifications, [["next", 1]]); + + overrideGlobals = false; +}); + +Deno.test("throttle should ignore values during throttle window", () => { + // Arrange + let overrideGlobals = true; + const notifications: Array> = []; + const setTimeoutCalls: Array> = []; + const originalSetTimeout = globalThis.setTimeout; + Object.defineProperty(globalThis, "setTimeout", { + value: (...args: Parameters) => { + setTimeoutCalls.push(args); + return overrideGlobals ? setTimeoutCalls.length : originalSetTimeout(...args); + }, + }); + + const source = new Subject(); + const materialized = pipe(source, throttle(100), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + source.next(1); + source.next(2); + source.next(3); + assertEquals(notifications, [["next", 1]]); + const [[callback]] = setTimeoutCalls; + (callback as () => void)(); + source.next(4); + + // Assert + assertEquals(notifications, [["next", 1], ["next", 4]]); + + overrideGlobals = false; +}); + +Deno.test("throttle should pump throws right through itself", () => { + // Arrange + let overrideGlobals = true; + const error = new Error("test error"); + const notifications: Array> = []; + const originalSetTimeout = globalThis.setTimeout; + Object.defineProperty(globalThis, "setTimeout", { + value: (...args: Parameters) => { + return overrideGlobals ? Math.random() : originalSetTimeout(...args); + }, + }); + + const source = new Observable((observer) => { + observer.next(1); + observer.throw(error); + }); + const materialized = pipe(source, throttle(100), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["throw", error], + ]); + + overrideGlobals = false; +}); + +Deno.test("throttle should honor unsubscribe", () => { + // Arrange + let overrideGlobals = true; + const controller = new AbortController(); + const notifications: Array> = []; + const setTimeoutCalls: Array> = []; + const clearTimeoutCalls: Array> = []; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + Object.defineProperty(globalThis, "setTimeout", { + value: (...args: Parameters) => { + setTimeoutCalls.push(args); + return overrideGlobals ? setTimeoutCalls.length : originalSetTimeout(...args); + }, + }); + Object.defineProperty(globalThis, "clearTimeout", { + value: (...args: Parameters) => { + clearTimeoutCalls.push(args); + return overrideGlobals ? undefined : originalClearTimeout(...args); + }, + }); + + const source = new Subject(); + const materialized = pipe(source, throttle(100), materialize()); + + // Act + materialized.subscribe( + new Observer({ + signal: controller.signal, + next: (notification) => { + notifications.push(notification); + controller.abort(); + }, + }), + ); + source.next(1); + + // Assert + assertEquals(notifications, [["next", 1]]); + + overrideGlobals = false; +}); + +Deno.test("throttle should throw when called with no arguments", () => { + // Arrange / Act / Assert + assertThrows( + () => throttle(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + +Deno.test("throttle should throw when milliseconds is not a number", () => { + // Arrange / Act / Assert + assertThrows( + () => throttle("s" as unknown as number), + TypeError, + "Parameter 1 is not of type 'Number'", + ); +}); + +Deno.test("throttle should throw when called with no source", () => { + // Arrange + const operator = throttle(100); + + // Act / Assert + assertThrows( + () => operator(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + +Deno.test("throttle should throw when source is not an Observable", () => { + // Arrange + const operator = throttle(100); + + // Act / Assert + assertThrows( + // deno-lint-ignore no-explicit-any + () => operator(1 as any), + TypeError, + "Parameter 1 is not of type 'Observable'", + ); +}); + +Deno.test("throttle should emit all values immediately when milliseconds is 0", () => { + // Arrange + const notifications: Array> = []; + const source = of([1, 2, 3]); + const materialized = pipe(source, throttle(0), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [ + ["next", 1], + ["next", 2], + ["next", 3], + ["return"], + ]); +}); + +Deno.test("throttle should work with Infinity milliseconds", () => { + // Arrange + const notifications: Array> = []; + const source = of([1, 2, 3]); + const materialized = pipe(source, throttle(Infinity), materialize()); + + // Act + materialized.subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Assert + assertEquals(notifications, [["next", 1]]); +}); diff --git a/throttle/mod.ts b/throttle/mod.ts new file mode 100644 index 0000000..c6004db --- /dev/null +++ b/throttle/mod.ts @@ -0,0 +1,59 @@ +import { isObservable, type Observable } from "@observable/core"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; +import { empty } from "@observable/empty"; +import { pipe } from "@observable/pipe"; +import { exhaustMap } from "@observable/exhaust-map"; +import { flat } from "@observable/flat"; +import { of } from "@observable/of"; +import { timer } from "@observable/timer"; +import { ignoreElements } from "@observable/ignore-elements"; + +/** + * Throttles the emission of values from the [source](https://jsr.io/@observable/core#source) + * [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) by the specified number of {@linkcode milliseconds}. + * @example + * ```ts + * import { throttle } from "@observable/throttle"; + * import { Subject } from "@observable/core"; + * import { pipe } from "@observable/pipe"; + * + * const controller = new AbortController(); + * const source = new Subject(); + * + * pipe(source, throttle(100)).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), + * return: () => console.log("return"), + * throw: (value) => console.log("throw", value), + * }); + * + * source.next(1); // Emitted immediately + * source.next(2); // Ignored (within throttle window) + * source.next(3); // Ignored (within throttle window) + * + * // After 100ms, the next value will be emitted + * source.next(4); // Emitted after throttle window + * + * // Console output: + * // "next" 1 + * // (after 100ms) + * // "next" 4 + * ``` + */ +export function throttle( + milliseconds: number, +): (source: Observable) => Observable { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (typeof milliseconds !== "number") { + throw new ParameterTypeError(0, "Number"); + } + return function throttleFn(source) { + if (arguments.length === 0) throw new MinimumArgumentsRequiredError(); + if (!isObservable(source)) throw new ParameterTypeError(0, "Observable"); + if (milliseconds < 0 || Number.isNaN(milliseconds)) return empty; + return pipe( + source, + exhaustMap((value) => flat([of([value]), pipe(timer(milliseconds), ignoreElements())])), + ); + }; +} diff --git a/throw-error/README.md b/throw-error/README.md new file mode 100644 index 0000000..76a70e8 --- /dev/null +++ b/throw-error/README.md @@ -0,0 +1,37 @@ +# @observable/throw-error + +Creates an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that will +[`throw`](https://jsr.io/@observable/core/doc/~/Observer.throw) the given value immediately upon +[`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { throwError } from "@observable/throw-error"; + +const controller = new AbortController(); + +throwError(new Error("throw")).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), // Never called + return: () => console.log("return"), // Never called + throw: (value) => console.log("throw", value), // Called immediately +}); +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/throw-error/deno.json b/throw-error/deno.json new file mode 100644 index 0000000..f966363 --- /dev/null +++ b/throw-error/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/throw-error", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/throw-error.test.ts b/throw-error/mod.test.ts similarity index 68% rename from common/throw-error.test.ts rename to throw-error/mod.test.ts index df97c0e..3bccdfb 100644 --- a/common/throw-error.test.ts +++ b/throw-error/mod.test.ts @@ -1,9 +1,8 @@ -import { Observer } from "@xan/observable-core"; -import { throwError } from "./throw-error.ts"; +import { Observer } from "@observable/core"; +import { throwError } from "./mod.ts"; import { assertEquals } from "@std/assert"; -import { materialize } from "./materialize.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { pipe } from "./pipe.ts"; +import { materialize, type ObserverNotification } from "@observable/materialize"; +import { pipe } from "@observable/pipe"; Deno.test( "throwError should push an error to the observer immediately upon subscription", diff --git a/throw-error/mod.ts b/throw-error/mod.ts new file mode 100644 index 0000000..43bbea8 --- /dev/null +++ b/throw-error/mod.ts @@ -0,0 +1,24 @@ +import { Observable } from "@observable/core"; + +/** + * Creates an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that will [`throw`](https://jsr.io/@observable/core/doc/~/Observer.throw) the + * given `value` immediately upon [`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). + * + * @param value The value to [`throw`](https://jsr.io/@observable/core/doc/~/Observer.throw). + * @example + * ```ts + * import { throwError } from "@observable/throw-error"; + * + * const controller = new AbortController(); + * + * throwError(new Error("throw")).subscribe({ + * signal: controller.signal, + * next: (value) => console.log("next", value), // Never called + * return: () => console.log("return"), // Never called + * throw: (value) => console.log("throw", value), // Called immediately + * }); + * ``` + */ +export function throwError(value: unknown): Observable { + return new Observable((observer) => observer.throw(value)); +} diff --git a/timer/README.md b/timer/README.md new file mode 100644 index 0000000..079e07a --- /dev/null +++ b/timer/README.md @@ -0,0 +1,77 @@ +# @observable/timer + +Creates an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that +[`next`](https://jsr.io/@observable/core/doc/~/Observer.next)s a `0` value after a specified number +of milliseconds and then [`return`](https://jsr.io/@observable/core/doc/~/Observer.return)s. + +## Build + +Automated by [JSR](https://jsr.io/) + +## Publishing + +Automated by `.github\workflows\publish.yml`. + +## Running unit tests + +Run `deno task test` or `deno task test:ci` to execute the unit tests via +[Deno](https://deno.land/). + +## Example + +```ts +import { timer } from "@observable/timer"; + +const controller = new AbortController(); +timer(1_000).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output (after 1 second): +// "next" 0 +// "return" +``` + +## Synchronous completion with 0ms + +```ts +import { timer } from "@observable/timer"; + +const controller = new AbortController(); +timer(0).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output (synchronously): +// "next" 0 +// "return" +``` + +## Edge cases + +```ts +import { timer } from "@observable/timer"; + +const controller = new AbortController(); + +// Negative values return immediately +timer(-1).subscribe({ + signal: controller.signal, + next: (value) => console.log("next", value), + return: () => console.log("return"), + throw: (value) => console.log("throw", value), +}); + +// Console output (synchronously): +// "return" +``` + +# Glossary And Semantics + +[@observable/core](https://jsr.io/@observable/core#glossary-and-semantics) diff --git a/timer/deno.json b/timer/deno.json new file mode 100644 index 0000000..d4b4275 --- /dev/null +++ b/timer/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@observable/timer", + "version": "0.1.0", + "license": "MIT", + "exports": "./mod.ts" +} diff --git a/common/timer.test.ts b/timer/mod.test.ts similarity index 95% rename from common/timer.test.ts rename to timer/mod.test.ts index 123c94b..cff8674 100644 --- a/common/timer.test.ts +++ b/timer/mod.test.ts @@ -1,11 +1,10 @@ import { assertEquals, assertInstanceOf, assertStrictEquals, assertThrows } from "@std/assert"; -import { empty } from "./empty.ts"; -import { never } from "./never.ts"; -import { Observer } from "@xan/observable-core"; -import { timer } from "./timer.ts"; -import type { ObserverNotification } from "./observer-notification.ts"; -import { pipe } from "./pipe.ts"; -import { materialize } from "./materialize.ts"; +import { empty } from "@observable/empty"; +import { never } from "@observable/never"; +import { Observer } from "@observable/core"; +import { timer } from "./mod.ts"; +import { pipe } from "@observable/pipe"; +import { materialize, type ObserverNotification } from "@observable/materialize"; Deno.test("timer should return never if the milliseconds is Infinity", () => { // Arrange diff --git a/common/timer.ts b/timer/mod.ts similarity index 77% rename from common/timer.ts rename to timer/mod.ts index f91bd39..68dac0a 100644 --- a/common/timer.ts +++ b/timer/mod.ts @@ -1,9 +1,9 @@ -import { MinimumArgumentsRequiredError, ParameterTypeError } from "@xan/observable-internal"; -import { Observable } from "@xan/observable-core"; -import { empty } from "./empty.ts"; -import { of } from "./of.ts"; -import { never } from "./never.ts"; -import { flat } from "./flat.ts"; +import { MinimumArgumentsRequiredError, ParameterTypeError } from "@observable/internal"; +import { Observable } from "@observable/core"; +import { empty } from "@observable/empty"; +import { of } from "@observable/of"; +import { never } from "@observable/never"; +import { flat } from "@observable/flat"; /** * @internal Do NOT export. @@ -11,13 +11,13 @@ import { flat } from "./flat.ts"; const success = of([0]); /** - * Creates an [`Observable`](https://jsr.io/@xan/observable-core/doc/~/Observable) that - * [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)s a `0` value after a + * Creates an [`Observable`](https://jsr.io/@observable/core/doc/~/Observable) that + * [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)s a `0` value after a * specified number of {@linkcode milliseconds} and then - * [`return`](https://jsr.io/@xan/observable-core/doc/~/Observer.return)s. + * [`return`](https://jsr.io/@observable/core/doc/~/Observer.return)s. * @example * ```ts - * import { timer } from "@xan/observable-common"; + * import { timer } from "@observable/timer"; * * const controller = new AbortController(); * timer(1_000).subscribe({ @@ -33,7 +33,7 @@ const success = of([0]); * ``` * @example * ```ts - * import { timer } from "@xan/observable-common"; + * import { timer } from "@observable/timer"; * * const controller = new AbortController(); * timer(0).subscribe({ @@ -49,7 +49,7 @@ const success = of([0]); * ``` * @example * ```ts - * import { timer } from "@xan/observable-common"; + * import { timer } from "@observable/timer"; * * const controller = new AbortController(); * timer(-1).subscribe({ @@ -64,7 +64,7 @@ const success = of([0]); * ``` * @example * ```ts - * import { timer } from "@xan/observable-common"; + * import { timer } from "@observable/timer"; * * const controller = new AbortController(); * timer(NaN).subscribe({ diff --git a/web/README.md b/web/README.md deleted file mode 100644 index fd56794..0000000 --- a/web/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# @xan/observable-web - -[@xan/observable-core](https://jsr.io/@xan/observable-core) -[Web APIs](https://developer.mozilla.org/en-US/docs/Web/API) utilities. diff --git a/web/broadcast-subject-constructor.ts b/web/broadcast-subject-constructor.ts deleted file mode 100644 index aef34e5..0000000 --- a/web/broadcast-subject-constructor.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { BroadcastSubject } from "./broadcast-subject.ts"; - -/** - * Object interface for an {@linkcode BroadcastSubject} factory. - */ -export interface BroadcastSubjectConstructor { - /** - * Creates and returns a variant of [`Subject`](https://jsr.io/@xan/subject/doc/~/Subject). When values - * are [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed, they are - * {@linkcode structuredClone|structured cloned} and sent only to [consumers](https://jsr.io/@xan/observable-core#consumer) - * of _other_ {@linkcode BroadcastSubject} instances with the same {@linkcode name} even if they are in different browsing - * contexts (e.g. browser tabs). Logically, [consumers](https://jsr.io/@xan/observable-core#consumer) of the - * {@linkcode BroadcastSubject} do not receive it's _own_ - * [`next`](https://jsr.io/@xan/observable-core/doc/~/Observer.next)ed values. - * @example - * ```ts - * import { BroadcastSubject } from "@xan/observable-web"; - * - * // Setup subjects - * const name = "test"; - * const controller = new AbortController(); - * const subject1 = new BroadcastSubject(name); - * const subject2 = new BroadcastSubject(name); - * - * // Subscribe to subjects - * subject1.subscribe({ - * signal: controller.signal, - * next: (value) => console.log("subject1 received", value, "from subject1"), - * return: () => console.log("subject1 returned"), - * throw: (value) => console.log("subject1 threw", value), - * }); - * subject2.subscribe({ - * signal: controller.signal, - * next: (value) => console.log("subject2 received", value, "from subject2"), - * return: () => console.log("subject2 returned"), - * throw: (value) => console.log("subject2 threw", value), - * }); - * - * subject1.next(1); // subject2 received 1 from subject1 - * subject2.next(2); // subject1 received 2 from subject2 - * subject2.return(); // subject2 returned - * subject1.next(3); // No console output since subject2 is already returned - * ``` - */ - new (name: string): BroadcastSubject; - new (name: string): BroadcastSubject; - readonly prototype: BroadcastSubject; -} diff --git a/web/mod.test.ts b/web/mod.test.ts deleted file mode 100644 index 447c3b9..0000000 --- a/web/mod.test.ts +++ /dev/null @@ -1,4 +0,0 @@ -Deno.test("mod should be importable", async () => { - // Arrange / Act / Assert - await import("./mod.ts"); -}); diff --git a/web/mod.ts b/web/mod.ts deleted file mode 100644 index 0273398..0000000 --- a/web/mod.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { BroadcastSubjectConstructor } from "./broadcast-subject-constructor.ts"; -export { BroadcastSubject } from "./broadcast-subject.ts";