diff --git a/lib/mod.ts b/lib/mod.ts index e15e4d3c5..64d8bd609 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -25,3 +25,4 @@ export * from "./with-resolvers.ts"; export * from "./async.ts"; export * from "./scoped.ts"; export * from "./until.ts"; +export * from "./using.ts"; diff --git a/lib/using.ts b/lib/using.ts new file mode 100644 index 000000000..9d39416da --- /dev/null +++ b/lib/using.ts @@ -0,0 +1,50 @@ +import { call } from "./call.ts"; +import { resource } from "./resource.ts"; +import type { Operation } from "./types.ts"; + +/** + * Bind a JavaScript disposable value to the current Effection scope. + * + * The provided value is yielded immediately, then disposed when the owning + * scope exits (on return, error, or halt). + * + * @example + * ```ts + * import { run, using } from "effection"; + * + * class Connection { + * opened = true; + * [Symbol.dispose]() { + * this.opened = false; + * } + * } + * + * await run(function* () { + * let connection = yield* using(new Connection()); + * connection.opened; // true while in scope + * }); + * ``` + */ +export function* using( + value: T, +): Operation { + let dispose = Symbol.asyncDispose in value + ? value[Symbol.asyncDispose] + : Symbol.dispose in value + ? value[Symbol.dispose] + : undefined; + + if (!dispose) { + throw new TypeError( + "using() value must implement Symbol.dispose or Symbol.asyncDispose", + ); + } + + return yield* resource(function* (provide) { + try { + yield* provide(value); + } finally { + yield* call(() => dispose.call(value)); + } + }); +} diff --git a/test/using.test.ts b/test/using.test.ts new file mode 100644 index 000000000..d7b1cc064 --- /dev/null +++ b/test/using.test.ts @@ -0,0 +1,138 @@ +import { createScope, run, suspend, using } from "../mod.ts"; +import { describe, expect, it } from "./suite.ts"; + +describe("using", () => { + it("should dispose sync disposable value without the native 'using' keyword", async () => { + let value: number | undefined; + let resource = new Resource(); + + await run(function* () { + let ref = yield* using(resource); + value = ref.getValue(); + }); + + expect(value).toBeDefined(); + expect(value).toBe(100); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("should dispose async disposable value without the native 'using' keyword", async () => { + let value: number | undefined; + let resource = new AsyncResource(); + + await run(function* () { + let ref = yield* using(resource); + value = ref.getValue(); + }); + + expect(value).toBeDefined(); + expect(value).toBe(100); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("disposes resources when the operation errors", async () => { + let resource = new Resource(); + let error = new Error("boom"); + + await expect( + run(function* () { + yield* using(resource); + throw error; + }), + ).rejects.toBe(error); + + expect(resource.isDisposed).toBeTruthy(); + }); + + it("disposes resources when the owning scope is halted", async () => { + let [scope, destroy] = createScope(); + let resource = new Resource(); + let resolver: (() => void) | undefined; + let started = new Promise((resolve) => (resolver = resolve)); + + let task = scope.run(function* () { + yield* using(resource); + resolver?.(); + yield* suspend(); + }); + + await started; + await expect(destroy()).resolves.toBeUndefined(); + await expect(task).rejects.toThrow("halted"); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("waits for async disposal before completing", async () => { + let resource = new DelayedAsyncResource(); + + await run(function* () { + yield* using(resource); + }); + + expect(resource.disposeStarted).toBeTruthy(); + expect(resource.isDisposed).toBeTruthy(); + }); + + it("errors on non-disposable runtime values", async () => { + await expect( + run(function* () { + // deno-lint-ignore no-explicit-any + yield* using({} as any); + }), + ).rejects.toThrow(); + }); +}); + +class Resource { + value = 100; + isDisposed = false; + + getValue() { + if (this.isDisposed) { + throw new Error("Resource is disposed"); + } + return this.value; + } + + [Symbol.dispose]() { + this.isDisposed = true; + } +} + +class AsyncResource { + value = 100; + isDisposed = false; + + getValue() { + if (this.isDisposed) { + throw new Error("Resource is disposed"); + } + return this.value; + } + + async [Symbol.asyncDispose]() { + await Promise.resolve(void 0); + this.isDisposed = true; + } +} + +class DelayedAsyncResource { + isDisposed = false; + disposeStarted = false; + + async [Symbol.asyncDispose]() { + this.disposeStarted = true; + + let id: number | undefined; + + try { + await new Promise((resolve) => { + id = setTimeout(resolve, 20); + }); + + this.isDisposed = true; + } finally { + if (id) clearTimeout(id); + } + } +}