diff --git a/channel.ts b/_channel.ts similarity index 94% rename from channel.ts rename to _channel.ts index a00162c..2edc255 100644 --- a/channel.ts +++ b/_channel.ts @@ -5,6 +5,7 @@ import type { } from "./types.ts"; import { ReadableStreamSet, enhanceReadableStream } from "./disposal.ts"; +import { streamTee } from "./_stream.ts"; /** * Creates a unidirectional communication channel built on Web Streams, allowing data to flow from one or more writers to multiple independent readers. @@ -141,7 +142,8 @@ export function createChannel(): Channel { const sharedWriter = transformStream.writable.getWriter(); const readableStream = transformStream.readable; - let enhancedReadableStream = enhanceReadableStream(readableStream); + const enhancedReadableStream = enhanceReadableStream(readableStream); + let currentReadableStream = enhancedReadableStream; return { /** @@ -164,11 +166,11 @@ export function createChannel(): Channel { * @returns A new readable stream with disposal support. */ get readable(): EnhancedReadableStream { - const [branch1, branch2] = enhancedReadableStream.tee(); + const [branch1, branch2] = streamTee(currentReadableStream as ReadableStream); const wrappedBranch1 = enhanceReadableStream(branch1); ReadableStreamSet.add(wrappedBranch1); - enhancedReadableStream = wrappedBranch1; // Keep one branch for further teeing + currentReadableStream = wrappedBranch1; // Keep one branch for further teeing const wrappedBranch2 = enhanceReadableStream(branch2); ReadableStreamSet.add(wrappedBranch2); @@ -179,21 +181,28 @@ export function createChannel(): Channel { * Closes the channel by closing the shared writer and canceling all branches of the readable stream. */ close() { - sharedWriter.close(); // Close the writable stream - ReadableStreamSet.forEach((stream) => { - if (stream.locked) { - stream.getReader().releaseLock(); // Release the reader lock - } + // sharedWriter.close(); // Close the writable stream + // ReadableStreamSet.forEach((stream) => { + // return stream[Symbol.dispose](); - // Cancel all readable branches - return stream.cancel(); - }); + // if (stream.locked) { + // console.log({ dispose: stream[Symbol.dispose] }) + // stream.getReader().releaseLock(); // Release the reader lock + // } - if (readableStream.locked) { - readableStream.getReader().releaseLock(); // Release the reader lock + // // Cancel all readable branches + // return stream.cancel(); + // }); + + console.log({ + enhancedReadableStream: enhancedReadableStream + }) + if (enhancedReadableStream.locked) { + const reader = enhancedReadableStream.getReader(); + reader.releaseLock(); // Release the reader lock } - readableStream.cancel(); + enhancedReadableStream.cancel(); ReadableStreamSet.clear(); // Clear the set of readers }, diff --git a/idle.ts b/_idle.ts similarity index 88% rename from idle.ts rename to _idle.ts index ebebc20..4fbba8f 100644 --- a/idle.ts +++ b/_idle.ts @@ -88,8 +88,10 @@ export function idle( const timeout = (options.timeout ??= IDLE_TIMEOUT); if ( - "requestIdleCallback" in globalThis && - "cancelIdleCallback" in globalThis + // @ts-ignore requestIdleCallback is not always available in all environments + ("requestIdleCallback" in globalThis && globalThis?.requestIdleCallback) && + // @ts-ignore cancelIdleCallback is not always available in all environments + ("cancelIdleCallback" in globalThis && globalThis?.cancelIdleCallback) ) { // Use requestIdleCallback if available return globalThis?.requestIdleCallback?.(callback, options); @@ -135,8 +137,10 @@ export function idle( */ export function cancelIdle(handle: ReturnType): void { if ( - "requestIdleCallback" in globalThis && - "cancelIdleCallback" in globalThis + // @ts-ignore requestIdleCallback is not always available in all environments + ("requestIdleCallback" in globalThis && globalThis?.requestIdleCallback) && + // @ts-ignore cancelIdleCallback is not always available in all environments + ("cancelIdleCallback" in globalThis && globalThis?.cancelIdleCallback) ) { // Use cancelIdleCallback if available return globalThis?.cancelIdleCallback?.( diff --git a/_idle_test.ts b/_idle_test.ts new file mode 100644 index 0000000..8520fd7 --- /dev/null +++ b/_idle_test.ts @@ -0,0 +1,217 @@ +import { test } from "@libs/testing"; +import { expect } from "@std/expect"; +import { idle, cancelIdle, IDLE_TIMEOUT } from "./_idle.ts"; + +/** + * Utility functions to simulate the presence or absence of `requestIdleCallback`. + */ +function simulateRequestIdleCallbackAvailable() { + const originalRequestIdleCallback = globalThis.requestIdleCallback; + const originalCancelIdleCallback = globalThis.cancelIdleCallback; + + globalThis.requestIdleCallback = function ( + callback: IdleRequestCallback, + options?: IdleRequestOptions, + ): number { + const handle = setTimeout(() => { + callback({ + didTimeout: false, + timeRemaining: () => 50, + }); + }, 0); + return handle; + }; + + globalThis.cancelIdleCallback = function (handle: number): void { + clearTimeout(handle); + }; + + return () => { + globalThis.requestIdleCallback = originalRequestIdleCallback; + globalThis.cancelIdleCallback = originalCancelIdleCallback; + }; +} + +function simulateRequestIdleCallbackUnavailable() { + const originalRequestIdleCallback = globalThis.requestIdleCallback; + const originalCancelIdleCallback = globalThis.cancelIdleCallback; + + delete (globalThis as any).requestIdleCallback; + delete (globalThis as any).cancelIdleCallback; + + return () => { + globalThis.requestIdleCallback = originalRequestIdleCallback; + globalThis.cancelIdleCallback = originalCancelIdleCallback; + }; +} + +// Test Case 1: Idle callback is called with `requestIdleCallback` available +test("all")( + "idle callback is called with requestIdleCallback available", + async () => { + const restore = simulateRequestIdleCallbackAvailable(); + + let callbackCalled = false; + + const handle = idle((deadline) => { + callbackCalled = true; + expect(deadline.didTimeout).toBe(false); + expect(deadline.timeRemaining()).toBeGreaterThan(0); + }); + + // Wait for the idle callback to be called + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(callbackCalled).toBe(true); + + // Clean up + restore(); + }, +); + +// Test Case 2: Idle callback is called without `requestIdleCallback` (fallback) +test.only("all")( + "idle callback is called without requestIdleCallback (fallback)", + async () => { + const restore = simulateRequestIdleCallbackUnavailable(); + + let callbackCalled = false; + + const handle = idle((deadline) => { + callbackCalled = true; + expect(deadline.didTimeout).toBe(false); + expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0); + }); + + // Wait for the fallback timeout to be called + await new Promise((resolve) => setTimeout(resolve, IDLE_TIMEOUT + 10)); + + expect(callbackCalled).toBe(true); + + // Clean up + restore(); + }, +); + +// Test Case 4: Cancel idle callback before it's called (`requestIdleCallback` available) +test("all")( + "idle callback is not called if canceled before execution with requestIdleCallback", + async () => { + const restore = simulateRequestIdleCallbackAvailable(); + + let callbackCalled = false; + + const handle = idle(() => { + callbackCalled = true; + }); + + cancelIdle(handle); + + // Wait to ensure callback would have been called + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(callbackCalled).toBe(false); + + // Clean up + restore(); + }, +); + +// Test Case 5: Cancel idle callback before it's called (fallback scenario) +test("all")( + "idle callback is not called if canceled before execution without requestIdleCallback", + async () => { + const restore = simulateRequestIdleCallbackUnavailable(); + + let callbackCalled = false; + + const handle = idle(() => { + callbackCalled = true; + }); + + cancelIdle(handle); + + // Wait to ensure callback would have been called + await new Promise((resolve) => setTimeout(resolve, IDLE_TIMEOUT + 10)); + + expect(callbackCalled).toBe(false); + + // Clean up + restore(); + }, +); + +// Test Case 6: Schedule multiple idle callbacks and cancel one +test("all")("only non-canceled idle callbacks are called", async () => { + const restore = simulateRequestIdleCallbackUnavailable(); + + let callback1Called = false; + let callback2Called = false; + + const handle1 = idle(() => { + callback1Called = true; + }); + + const handle2 = idle(() => { + callback2Called = true; + }); + + cancelIdle(handle1); + + // Wait for callbacks to be called + await new Promise((resolve) => setTimeout(resolve, IDLE_TIMEOUT + 10)); + + expect(callback1Called).toBe(false); + expect(callback2Called).toBe(true); + + // Clean up + restore(); +}); + +// Test Case 7: Callback throws an error +test("all")("error in idle callback is propagated", async () => { + const restore = simulateRequestIdleCallbackUnavailable(); + + const errorMessage = "Test error"; + let errorCaught = false; + + try { + const handle = idle(() => { + throw new Error(errorMessage); + }); + + // Wait for the callback to be called + await new Promise((resolve) => setTimeout(resolve, IDLE_TIMEOUT + 10)); + } catch (error) { + errorCaught = true; + expect(error.message).toBe(errorMessage); + } + + expect(errorCaught).toBe(true); + + // Clean up + restore(); +}); + +// Test Case 8: Schedule idle callback with zero timeout +test("all")("idle callback is called immediately with zero timeout", async () => { + const restore = simulateRequestIdleCallbackUnavailable(); + + let callbackCalled = false; + + const handle = idle( + (deadline) => { + callbackCalled = true; + expect(deadline.didTimeout).toBe(false); + }, + { timeout: 0 }, + ); + + // Wait a minimal amount of time + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callbackCalled).toBe(true); + + // Clean up + restore(); +}); diff --git a/iter.ts b/_iter.ts similarity index 99% rename from iter.ts rename to _iter.ts index 9fbeff7..239fb2d 100644 --- a/iter.ts +++ b/_iter.ts @@ -4,7 +4,7 @@ import { splitByStream, splitStream, streamToAsyncIterator, -} from "./stream.ts"; +} from "./_stream.ts"; /** * Splits a source iterator or iterable into two separate async iterators: one for valid values and one for errors encountered during iteration. diff --git a/iter_test.ts b/_iter_test.ts similarity index 97% rename from iter_test.ts rename to _iter_test.ts index a426b0f..9990a6e 100644 --- a/iter_test.ts +++ b/_iter_test.ts @@ -1,4 +1,4 @@ -import { splitIter, splitIterBy } from "./iter.ts"; +import { splitIter, splitIterBy } from "./_iter.ts"; import { test } from "@libs/testing"; import { expect } from "@std/expect"; diff --git a/_main.ts b/_main.ts new file mode 100644 index 0000000..808cca2 --- /dev/null +++ b/_main.ts @@ -0,0 +1,17 @@ +import { Worker } from "node:worker_threads"; + +const worker = new Worker(new URL("./_worker.mjs", import.meta.url), { type: "module" }); + +const transform = new TransformStream(); +const readable = new ReadableStream(); +// worker.postMessage({ readable: transform.readable }, [transform.readable]) + +const obj = structuredClone({ readable, transform }, { + transfer: [readable, transform] +}) + +console.log({ main: transform, obj }) + +// worker.onmessage = (evt) => { +// console.log({ main: evt, transform }) +// } \ No newline at end of file diff --git a/_repl.ts b/_repl.ts index 4a420b1..5bc59f4 100644 --- a/_repl.ts +++ b/_repl.ts @@ -1,145 +1,108 @@ +import { enhanceReadableStream, timeout } from "./disposal.ts"; +import { streamTee } from "./_stream.ts"; + import * as Future from "./mod.ts"; +import { delay } from "./deadline.ts"; async function* gen(_: unknown, stack: AsyncDisposableStack) { // throw new Error("Random message"); // yield new Error("Random message"); yield 154; - yield 200; + // yield 200; - // Simulate a long-running task - await stack.use(Future.delay(1000, 50)); - - yield 42; - yield 54; - yield 65; + // // Simulate a long-running task + // await stack.use(Future.delay(1000, 50)); + + // yield 42; + // yield 54; + // yield 65; return 42; } -const result = await Promise.all([ - (async () => { - // using future = Future.delay(5000, 50); - using iterator = Future.inBackground( - Future.from(gen) - ); - - for await (const value of iterator) { - console.log("inBackground - Value:", value); - } - - console.log(await iterator) - - return await iterator; - })(), - // (async () => { - // try { - // const iterator = Future.from(gen); - // // setTimeout(() => iterator.cancel(), 1000); - - // for await (const value of iterator) { - // console.log("Normal Value:", value); - // } - // } catch (e) { - // console.log("Future", { e }); - // } - // })(), - // (async () => { - // try { - // const iterator = Future.withConcurrencyLimit([ - // Future.from(gen), - // Future.inBackground( - // Future.from(async function* () { - // // throw new Error("Random message"); - - // yield "---> InBackground: 154"; - // yield "---> InBackground: 200"; - // // Simulate a long-running task - // await Future.delay(1000, 50); - // // yield new Promise((resolve, reject) => { - // // const timeout = setTimeout(resolve, 100, 50); - // // abort.signal.addEventListener("abort", () => { - // // clearTimeout(timeout); - // // reject(new Error("Aborted")); - // // }, { once: true }); - // // }); - // yield "---> InBackground: 42"; - // yield "---> InBackground: 54"; - // yield "---> InBackground: 65"; - - // // yield new Error("Random message"); - // return "---> InBackground: 42"; - // }) - // ), - // ], 4); - // // setTimeout(() => iterator.cancel(), 1000); - - // for await (const value of iterator) { - // console.log("Concurrent Value:", value); - // } - // } catch (e) { - // console.log("Future", { e }); - // } - // })(), -]); - -console.log("Done!!!", result); - -// const futures = [ -// Future.from(async function* () { -// yield 42; -// return 100; -// }), -// Future.from(async function* () { -// yield 10; -// return 20; -// }), -// Future.from(async function*(_, stack) { -// return [ -// yield* stack.use(Future.delay(0, "cool")), -// yield* stack.use(Future.delay(2000, "delay")), -// yield* stack.use(Future.delay(2000, "what")), -// ]; -// }) -// ]; -// const limitedFuture = Future.withConcurrencyLimit(futures, 2); -// console.log( -// limitedFuture, -// ) -// // Yield intermediate results -// for await (const value of limitedFuture) { -// console.log(value); // Logs 42 and 10 as they are yielded + +// const iterator = Future.from(gen); +// // Future.inBackground(); + +// for await (const value of iterator) { +// console.log("inBackground - Value:", value); // } -// // Get the final results -// const finalResults = await limitedFuture.toPromise(); -// console.log(finalResults); // Logs [100, 20], which are the final results of the futures. +// console.log("result", await iterator); +// iterator[Symbol.dispose](); -// // Get the final results -// const finalResults2 = await limitedFuture.toPromise(); -// console.log(finalResults2); -// const channel = createChannel(); -// // Get the writer and write values -// const writer = channel.getWriter(); -// writer.write(42); -// writer.write(47); +const transformStream = new TransformStream(); +const writableStream = transformStream.writable; +const readableStream = transformStream.readable; -// // Close the writer to indicate no more values will be written -// writer.close(); +const enhancedReadableStream = enhanceReadableStream(readableStream); +let currentReadableStream = enhancedReadableStream; -// const future = Future.delay(1000, 50); +const writer = writableStream.getWriter(); +writer.write(1); +writer.write(2); +writer.write(3); -// // Use the channel's readable stream and log the values -// using readable = channel.readable; -// for await (const value of readable) { -// console.log("Channel Value:", value); -// } +const [branch1, branch2] = streamTee(currentReadableStream); -// // Now this will be logged after the readable loop is done -// console.log({ -// future: await future, -// }); +const wrappedBranch1 = enhanceReadableStream(branch1); +const wrappedBranch2 = enhanceReadableStream(branch2); +currentReadableStream = wrappedBranch1; +// (async () => { +// await Promise.all([ +// Promise.all([ +// (async () => { +// for await (const value of wrappedBranch1) { +// console.log("Branch 1 - Value:", value); +// } +// })(), + +// (async () => { +// for await (const value of wrappedBranch2) { +// console.log("Branch 2 - Value:", value); +// } +// })(), +// ]), + +// // timeout(1000).then(() => writer.close()), +// ]); +// })(); + + +(async () => { + await timeout(1000, { reject: false }); + + console.log({ view: "2nd effect" }) + + const [branch3, branch4] = streamTee(currentReadableStream); + + const wrappedBranch3 = enhanceReadableStream(branch3); + const wrappedBranch4 = enhanceReadableStream(branch4); + + writer.write(4); + writer.write(5); + writer.write(6); + + Promise.all([ + Promise.all([ + (async () => { + for await (const value of wrappedBranch3) { + console.log("Branch 3 - Value:", value); + } + })(), + + (async () => { + for await (const value of wrappedBranch4) { + console.log("Branch 4 - Value:", value); + } + })(), + ]), + + timeout(2_000, { reject: false }).finally(() => writer.close()), + ]); +})(); \ No newline at end of file diff --git a/stream.ts b/_stream.ts similarity index 60% rename from stream.ts rename to _stream.ts index ff6cfa9..753b0aa 100644 --- a/stream.ts +++ b/_stream.ts @@ -1,5 +1,6 @@ import type { EnhancedReadableStream, DualDisposable } from "./types.ts"; -import { createChannel } from "./channel.ts"; +import { createChannel } from "./_channel.ts"; +import { enhanceReadableStream } from "./disposal.ts"; /** * Converts a ReadableStream into an Async Iterator. @@ -212,6 +213,157 @@ export function splitStream( ); } +/** + * Polyfill for `ReadableStream.tee()` + * + * This function splits a single `ReadableStream` (source stream) into two identical `ReadableStream` branches. + * It allows two consumers to read from the same data stream without conflicting with each other. This is useful in situations where you want to "clone" the data flow from a stream so that multiple readers can consume the same data independently. + * + * ## What is a ReadableStream? + * A `ReadableStream` is a way to handle data in chunks. Think of it as a flow of information—like a stream of water—that can be read piece by piece. For example, it might be useful when downloading a file, streaming video, or processing data over time. + * + * ## Why Use this Polyfill? + * The native `ReadableStream.tee()` method is used to split a stream into two branches, but it doesn't work well with certain enhanced `ReadableStream` features, like custom readers that avoid locking errors. This polyfill makes sure that the stream works seamlessly with enhanced streams, preventing issues when multiple readers try to access the stream. + * + * By using an `async` loop (`for await...of`), this polyfill ensures that the source stream’s data is efficiently copied to two branches without locking errors. + * + * ## How it Works: + * - The original `ReadableStream` is the "source" stream, which provides chunks of data. + * - This function creates two new `ReadableStream` branches that each get the same data as the source. + * - These two streams can be consumed independently, meaning two separate parts of your application can read the same data at the same time. + * + * @template T - The type of data flowing through the streams. + * + * @param source - The original `ReadableStream` that will be split into two. + * @returns [ReadableStream, ReadableStream] - An array containing two new streams that each receive the same data from the source stream. + * + * @example + * ### Basic Example: + * Imagine you are receiving chunks of data over time from a file download, and you want to display it in two different places at once—one for the user to see and another for logging purposes. This function allows you to split the data into two streams, so both parts of your code can process the data. + * + * ```typescript + * const sourceStream = new ReadableStream({ + * start(controller) { + * controller.enqueue("Chunk 1"); + * controller.enqueue("Chunk 2"); + * controller.close(); + * } + * }); + * + * // Split the stream into two + * const [branch1, branch2] = streamTee(sourceStream); + * + * // Set up the first reader + * const reader1 = branch1.getReader(); + * reader1.read().then(({ value }) => console.log("Stream 1 got:", value)); // Outputs: Chunk 1 + * + * // Set up the second reader + * const reader2 = branch2.getReader(); + * reader2.read().then(({ value }) => console.log("Stream 2 got:", value)); // Outputs: Chunk 1 + * ``` + * In this example, both `branch1` and `branch2` will receive the same chunks of data from the original stream. + * + * @example + * ### Processing in Parallel: + * If you need two independent processes to handle the same stream of data, you can use `streamTee` to split it into two. + * + * ```typescript + * const logStream = new ReadableStream({ + * start(controller) { + * controller.enqueue({ message: "Log entry 1" }); + * controller.enqueue({ message: "Log entry 2" }); + * controller.close(); + * } + * }); + * + * // Split the logStream into two separate streams + * const [logStreamCopy1, logStreamCopy2] = streamTee(logStream); + * + * // Process the logs in parallel + * async function processLogs(reader: ReadableStreamDefaultReader) { + * while (true) { + * const { done, value } = await reader.read(); + * if (done) break; + * console.log("Processing log:", value.message); + * } + * } + * + * processLogs(logStreamCopy1.getReader()); // Outputs: Processing log: Log entry 1 + * processLogs(logStreamCopy2.getReader()); // Outputs: Processing log: Log entry 1 + * ``` + * + * @example + * ### Why Use `for await...of`? + * This polyfill uses `for await...of` to read chunks from the source stream. This approach automatically handles asynchronous reading of the stream's data, making it easy to write code that waits for each chunk to be available without needing complex recursive code or error handling. + * + * ```typescript + * const sourceStream = new ReadableStream({ + * start(controller) { + * controller.enqueue('Data 1'); + * controller.enqueue('Data 2'); + * controller.close(); + * } + * }); + * + * const [branch1, branch2] = streamTee(sourceStream); + * + * // Both streams will receive the same data + * (async () => { + * for await (const chunk of branch1) { + * console.log('Branch 1 received:', chunk); + * } + * })(); + * + * (async () => { + * for await (const chunk of branch2) { + * console.log('Branch 2 received:', chunk); + * } + * })(); + * ``` + * The `for await...of` loop makes it simple to continuously read from streams as data arrives. It works well for streams because it automatically waits for the next chunk of data before continuing, simplifying the logic for handling asynchronous data flow. + */ +export function streamTee(source: ReadableStream): [ReadableStream, ReadableStream] { + const branch1 = new TransformStream(); + const branch2 = new TransformStream(); + + const writer1 = branch1.writable.getWriter(); + const writer2 = branch2.writable.getWriter(); + + console.log({ source }) + + // Get the reader from the source stream + const reader = source.getReader(); + + // Manually read from the source stream and write to both branches + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; // Exit loop when stream is done + } + + // Write the chunk to both branches + await Promise.all([ + writer1.write(value), + writer2.write(value), + ]); + } + } catch (error) { + console.error("Error reading from the stream:", error); + } finally { + // Close both branches when the original stream ends + await Promise.all([ + writer1.close(), + writer2.close(), + ]); + } + })(); + + return [branch1.readable, branch2.readable]; +} + /** * Splits a source ReadableStream into two separate ReadableStreams based on a predicate function. * diff --git a/utils.ts b/_utils.ts similarity index 100% rename from utils.ts rename to _utils.ts diff --git a/background.ts b/background.ts index 22750b0..0c529a2 100644 --- a/background.ts +++ b/background.ts @@ -1,5 +1,5 @@ import { Future } from "./future.ts"; -import { cancelIdle, idle } from "./idle.ts"; +import { cancelIdle, idle } from "./_idle.ts"; /** * Sets up a `Future` to execute in the background during idle time. @@ -23,7 +23,7 @@ export function inBackground( ): Future { // Iterate over the iterable/async iterable futures in a controlled manner return new Future(async function* (_, stack) { - const _future = stack.use(future); + const _future = future; // stack.use(future); // If no valid iterator was found, throw an error indicating that the input is not iterable or an iterator if ( diff --git a/disposal.ts b/disposal.ts index ae7fbbd..37ac369 100644 --- a/disposal.ts +++ b/disposal.ts @@ -17,7 +17,7 @@ import type { WithDisposal, ReadableStreamReaderWithDisposal, } from "./types.ts"; -import { isAsyncDisposable, isAsyncIterable, isDisposable, isIterable } from "./utils.ts"; +import { isAsyncDisposable, isAsyncIterable, isDisposable, isIterable } from "./_utils.ts"; import { AsyncDisposableStack as _AsyncDisposableStackPolyfill } from "@nick/dispose/async-disposable-stack"; import { DisposableStack as _DisposableStackPollyfill } from "@nick/dispose/disposable-stack"; @@ -48,7 +48,7 @@ export const ReadableStreamReaderMap: WeakMap< * their lifecycle and ensuring that resources are cleaned up appropriately when streams are * disposed of. */ -export const ReadableStreamSet: Set> = new Set(); +export const ReadableStreamSet: Set> = new Set(); /** * Wraps a `ReadableStream` and adds `Symbol.dispose` and `Symbol.asyncDispose` methods @@ -137,7 +137,7 @@ export function enhanceReadableStream( * @param reason - The reason for canceling the stream. * @returns A promise that resolves when the stream has been canceled. */ - async cancel(this: ReadableStream, ...args: Parameters["cancel"]>) { + async cancel(this: EnhancedReadableStream, ...args: Parameters["cancel"]>) { const result = await streamCancel.apply(this, args) ReadableStreamSet.delete(this); return result; @@ -152,9 +152,14 @@ export function enhanceReadableStream( * @param reason - The reason for disposing of the stream. */ [Symbol.dispose](this: EnhancedReadableStream, reason?: unknown) { + console.log({ + "this.locked": this.locked, + }) if (this.locked) { // If the stream is locked, get the reader and explicitly release the lock - this.getReader()?.[Symbol.dispose]?.(); + const reader = this.getReader(); + reader?.cancel(); + reader?.releaseLock?.(); } // If the stream is not locked, just cancel the stream directly @@ -173,7 +178,9 @@ export function enhanceReadableStream( async [Symbol.asyncDispose](this: EnhancedReadableStream, reason?: unknown) { if (this.locked) { // If the stream is locked, get the reader and explicitly release the lock - await this.getReader()?.[Symbol.asyncDispose]?.(); + const reader = this.getReader(); + await reader?.cancel(); + reader?.releaseLock?.(); } // If the stream is not locked, just cancel the stream directly @@ -429,18 +436,31 @@ export function abortable( }, }); } +/** + * Options for configuring the timeout behavior. + * + * @param reject - Whether the promise should reject (true) or resolve (false) after the timeout. Default is `true` (reject). + * @param abort - An optional `AbortController` or `AbortSignal` to allow for early cancellation of the timeout. + */ +export interface TimeoutOptions { + /** + * @default true + */ + reject?: boolean; + abort?: AbortController | AbortSignal; +} /** - * Creates a promise that rejects after a specified timeout and supports disposal. + * Creates a promise that either rejects or resolves after a specified timeout and supports disposal and aborting. * * @remarks - * This function is useful for enforcing time limits on asynchronous operations. If the operation takes longer than the specified timeout, the promise will be rejected. It also supports an optional abort signal to allow for early cancellation. + * This function is useful for enforcing time limits on asynchronous operations. If the operation takes longer than the specified timeout, the promise will either resolve or reject based on the provided options. It also supports an optional abort signal to allow for early cancellation. * - * @param ms - The number of milliseconds to wait before rejecting the promise. - * @param abort - An optional `AbortController` or `AbortSignal` to allow for early cancellation of the timeout. - * @returns A `PromiseWithDisposable` that rejects after the specified timeout or when aborted. + * @param ms - The number of milliseconds to wait before resolving/rejecting the promise. + * @param options - An optional configuration object to control the behavior of the timeout (resolve or reject) and aborting logic. + * @returns A `PromiseWithDisposal` that resolves or rejects after the specified timeout or when aborted. * - * @example + * @example Reject on timeout (default) * ```typescript * const timeoutPromise = timeout(5000); * @@ -454,10 +474,25 @@ export function abortable( * timeoutPromise[Symbol.dispose](); * ``` * + * @example Resolve on timeout + * ```typescript + * const timeoutPromise = timeout(5000, { resolveOnTimeout: true }); + * + * try { + * await timeoutPromise; + * console.log("Operation resolved after timeout"); + * } catch (error) { + * console.error("Unexpected error:", error); + * } + * + * // Clean up resources + * timeoutPromise[Symbol.dispose](); + * ``` + * * @example Using with AbortSignal * ```typescript * const controller = new AbortController(); - * const timeoutPromise = timeout(5000, controller.signal); + * const timeoutPromise = timeout(5000, { abort: controller.signal }); * * // Abort the operation before the timeout * setTimeout(() => controller.abort(), 1000); @@ -476,23 +511,28 @@ export function abortable( */ export function timeout( ms: number, - abort?: AbortController | AbortSignal, + options: TimeoutOptions = {} ): PromiseWithDisposal { - // Create a promise with external resolve and reject capabilities - const { promise, reject } = Promise.withResolvers(); + const { reject: rejectOnTimout = true, abort } = options; - // Set a timeout to reject the promise after the specified time - const timeoutId = setTimeout(reject, ms); + // Create a promise with external resolve and reject capabilities + const { promise, resolve, reject } = Promise.withResolvers(); + + // Set a timeout to resolve or reject the promise after the specified time + const timeoutId = setTimeout(() => { + if (rejectOnTimout) { + reject(new Error('Timeout exceeded')); + } else { + resolve(); + } + }, ms); // Create an abortable promise if an abort signal is provided const abortPromise = abort ? abortable(abort) : null; // Return a race between the timeout and the abortable promise (if provided) return Object.assign( - Promise.race([ - promise, - abortPromise, - ]), + abortPromise ? Promise.race([promise, abortPromise]) : promise, { [Symbol.dispose]() { // Clear the timeout to prevent memory leaks @@ -506,4 +546,4 @@ export function timeout( }, }, ) as PromiseWithDisposal; -} \ No newline at end of file +} diff --git a/events.ts b/events.ts index 76cf9ce..3f32fc0 100644 --- a/events.ts +++ b/events.ts @@ -7,7 +7,7 @@ import type { EnhancedReadableStream } from "./types.ts"; import type { StatusEnum, StatusEvent } from "./status.ts"; -import { createChannel } from "./channel.ts"; +import { createChannel } from "./_channel.ts"; /** * Creates a status event dispatcher that allows dispatching and listening to status events @@ -144,14 +144,14 @@ export function createStatusEventDispatcher(): StatusEventDispatcher { * Disposes of the dispatcher, releasing resources. */ [Symbol.dispose]() { - this.close(); + channel[Symbol.dispose](); }, /** * Disposes of the dispatcher asynchronously, releasing resources. */ [Symbol.asyncDispose]() { - return Promise.resolve(this[Symbol.dispose]()); + return channel[Symbol.asyncDispose](); }, }; } diff --git a/from.ts b/from.ts index ee9ed4f..863cb92 100644 --- a/from.ts +++ b/from.ts @@ -10,7 +10,7 @@ import { isIterable, isIterator, isPromiseLike, -} from "./utils.ts"; +} from "./_utils.ts"; import { Future } from "./future.ts"; /** diff --git a/future.ts b/future.ts index a898377..0f76992 100644 --- a/future.ts +++ b/future.ts @@ -190,10 +190,14 @@ export class Future implements throw new Error("Cannot reset a destroyed future"); } - if (!this.is(Status.Completed)) { - throw new Error("Cannot reset an incomplete future"); + if (!( + this.is(Status.Completed) || + this.is(Status.Cancelled) + )) { + throw new Error("Cannot reset a running or incomplete future"); } + this.#generator = null; this.#current = null; this.#resolved = null; @@ -232,59 +236,41 @@ export class Future implements const eventPromise = waitForEvent(events, Status.Cancelled); await Promise.all([ - eventPromise, + Promise.race([ + eventPromise, + timeout(GENERATOR_RETURN_TIMEOUT), + ]), this.#abort?.abort?.(reason), ]); return await eventPromise; } - dispose(): void { - this.#current = null; - this.#resolved = null; - - this.cancel(); - this.#disposables?.disposeAsync?.(); + async dispose(): Promise { + await this.cancel(); + await this.#disposables?.disposeAsync?.(); this.#abort?.signal?.removeEventListener?.("abort", this.#eventhandler); - this.#eventhandler?.dispose?.(); + await this.#eventhandler?.[Symbol.asyncDispose]?.(); + await this.#events?.[Symbol.asyncDispose]?.(); // @ts-ignore Resetting private properties this.#eventhandler = null as unknown; - this.#events?.close?.(); this.#abort = null; this.#operation = null; this.#generator = null; - this.#status = Status.Destroyed; - } - - [Symbol.dispose](): void { - this.dispose(); - } - - async [Symbol.asyncDispose](): Promise { this.#current = null; this.#resolved = null; - await this.cancel(); - await this.#disposables?.disposeAsync?.(); - - this.#abort?.signal?.removeEventListener?.("abort", this.#eventhandler); - this.#eventhandler?.dispose?.(); - - // @ts-ignore Resetting private properties - this.#eventhandler = null as unknown; - this.#events?.close?.(); - - this.#abort = null; - this.#operation = null; - this.#generator = null; - this.#status = Status.Destroyed; } + [Symbol.asyncDispose](): Promise { + return this.dispose(); + } + /** * Implements the async iterator protocol, allowing futures to be used in `for await...of` loops. * @@ -596,9 +582,9 @@ export class Future implements } return( - value: T | TReturn | PromiseLike, + value?: T | TReturn | PromiseLike, ): PromiseLike> { - return this.#generator!.return?.(value); + return this.#generator!.return?.(value!); } throw(e: unknown): PromiseLike> { @@ -651,6 +637,10 @@ export class Future implements async toPromise(): Promise { let result: IteratorResult; + if (this.is(Status.Completed) && this.#resolved !== null) { + return this.#resolved as T | TReturn; + } + // Iterate through the generator until completion do { result = await this.next() as IteratorResult< @@ -743,14 +733,17 @@ export class Future implements // } static #PrivateEventHandler = class PrivateEventHandler { - #delegate: Future | undefined | null; + #delegate: WeakRef> | undefined | null; constructor(delegate?: Future) { - this.#delegate = delegate; + if (delegate) { + this.#delegate = new WeakRef(delegate); + } } handleEvent(event: Event): void { if (this.#delegate) { - this.#delegate.#handleEvent(event); + const future = this.#delegate?.deref(); + if (future) future.#handleEvent(event); } } diff --git a/split.ts b/split.ts index 8b28d8e..af3a73d 100644 --- a/split.ts +++ b/split.ts @@ -1,8 +1,8 @@ import type { DualDisposable } from "./types.ts"; import type { Future } from "./future.ts"; -import { splitIter, splitIterBy } from "./iter.ts"; -import { from } from "./from.ts"; +import { splitIter, splitIterBy } from "./_iter.ts"; +import { fromIterator } from "./from.ts"; /** * Splits a Future into two futures: one for resolved values and one for errors. @@ -32,8 +32,8 @@ export function split( const [resolvedIterator, erroredIterator] = _split; return Object.assign( [ - from(resolvedIterator), - from(erroredIterator), + fromIterator(resolvedIterator), + fromIterator(erroredIterator), ] as const, { [Symbol.dispose]() { @@ -84,8 +84,8 @@ export function splitBy( const [matchedIterator, nonMatchedIterator] = _split; return Object.assign( [ - from(matchedIterator), - from(nonMatchedIterator), + fromIterator(matchedIterator), + fromIterator(nonMatchedIterator), ] as const, { [Symbol.dispose]() {