diff --git a/.changeset/add-async-client.md b/.changeset/add-async-client.md new file mode 100644 index 0000000..87d093e --- /dev/null +++ b/.changeset/add-async-client.md @@ -0,0 +1,5 @@ +--- +"ff-effect": patch +--- + +Add `asyncClient` utility that converts an Effect-based client into a normal async/await client. This is the inverse of `wrapClient` (Promise→Effect): it takes an Effect client and layer, creates a ManagedRuntime, and wraps all methods to return Promises instead of Effects. diff --git a/packages/effect/src/async-client.test.ts b/packages/effect/src/async-client.test.ts new file mode 100644 index 0000000..ac4008e --- /dev/null +++ b/packages/effect/src/async-client.test.ts @@ -0,0 +1,58 @@ +import { Effect, Layer } from 'effect'; +import { describe, expect, expectTypeOf, test } from 'vitest'; +import { asyncClient, type AsyncClient } from './async-client.js'; + +const TestLayer = Layer.empty; + +type TestClient = { + greet: (name: string) => Effect.Effect; + nested: { + add: (a: number, b: number) => Effect.Effect; + }; + fail: () => Effect.Effect; +}; + +const testClientEffect = Effect.succeed({ + greet: (name: string) => Effect.succeed(`hello ${name}`), + nested: { + add: (a: number, b: number) => Effect.succeed(a + b), + }, + fail: () => Effect.fail(new Error('test error')), +} satisfies TestClient); + +describe('asyncClient', () => { + test('wraps single-level methods', async () => { + const client = await asyncClient(testClientEffect, TestLayer); + const result = await client.greet('world'); + expect(result).toBe('hello world'); + await client.dispose(); + }); + + test('wraps nested group methods', async () => { + const client = await asyncClient(testClientEffect, TestLayer); + const result = await client.nested.add(1, 2); + expect(result).toBe(3); + await client.dispose(); + }); + + test('propagates errors as rejected promises', async () => { + const client = await asyncClient(testClientEffect, TestLayer); + await expect(client.fail()).rejects.toThrow('test error'); + await client.dispose(); + }); + + test('dispose cleans up runtime', async () => { + const client = await asyncClient(testClientEffect, TestLayer); + await client.dispose(); + }); + + test('type safety', () => { + expectTypeOf>().toEqualTypeOf<{ + greet: (name: string) => Promise; + nested: { + add: (a: number, b: number) => Promise; + }; + fail: () => Promise; + }>(); + }); +}); diff --git a/packages/effect/src/async-client.ts b/packages/effect/src/async-client.ts new file mode 100644 index 0000000..5a9bed0 --- /dev/null +++ b/packages/effect/src/async-client.ts @@ -0,0 +1,54 @@ +import { Effect, Layer, ManagedRuntime } from 'effect'; + +export type AsyncClient = { + [K in keyof T]: T[K] extends (...args: infer Args) => Effect.Effect + ? (...args: Args) => Promise + : T[K] extends Record + ? AsyncClient + : T[K]; +}; + +function wrapWithProxy>( + target: T, + runtime: ManagedRuntime.ManagedRuntime, +): AsyncClient { + const cache = new Map(); + return new Proxy(target, { + get(obj, prop) { + if (prop === 'then') return undefined; + if (cache.has(prop)) return cache.get(prop); + + const value = obj[prop as keyof T]; + if (typeof value === 'function') { + const wrapped = (...args: Array) => + runtime.runPromise(value(...args) as Effect.Effect); + cache.set(prop, wrapped); + return wrapped; + } + if (value !== null && typeof value === 'object') { + const wrapped = wrapWithProxy( + value as Record, + runtime, + ); + cache.set(prop, wrapped); + return wrapped; + } + return value; + }, + }) as AsyncClient; +} + +export async function asyncClient, E, R, ER>( + makeClient: Effect.Effect, + layer: Layer.Layer, +): Promise & { dispose: () => Promise }> { + const runtime = ManagedRuntime.make(layer); + const client = await runtime.runPromise(makeClient); + const proxied = wrapWithProxy(client, runtime); + const result = Object.create(proxied) as AsyncClient & { + dispose: () => Promise; + }; + result.dispose = () => runtime.dispose(); + return result; +} + diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts index cbeff6f..e77bb96 100644 --- a/packages/effect/src/index.ts +++ b/packages/effect/src/index.ts @@ -1,3 +1,4 @@ +export { asyncClient, type AsyncClient } from './async-client.js'; export { extract } from './extract.js'; export { runPromiseUnwrapped } from './run-promise-unwrapped.js'; export { wrapClient } from './wrap-client.js';