Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-async-client.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions packages/effect/src/async-client.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
nested: {
add: (a: number, b: number) => Effect.Effect<number>;
};
fail: () => Effect.Effect<never, Error>;
};

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<AsyncClient<TestClient>>().toEqualTypeOf<{
greet: (name: string) => Promise<string>;
nested: {
add: (a: number, b: number) => Promise<number>;
};
fail: () => Promise<never>;
}>();
});
});
54 changes: 54 additions & 0 deletions packages/effect/src/async-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Effect, Layer, ManagedRuntime } from 'effect';

export type AsyncClient<T> = {
[K in keyof T]: T[K] extends (...args: infer Args) => Effect.Effect<infer A, infer _E, infer _R>
? (...args: Args) => Promise<A>
: T[K] extends Record<string, unknown>
? AsyncClient<T[K]>
: T[K];
};

function wrapWithProxy<T extends Record<string, unknown>>(
target: T,
runtime: ManagedRuntime.ManagedRuntime<any, any>,
): AsyncClient<T> {
const cache = new Map<string | symbol, unknown>();
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<unknown>) =>
runtime.runPromise(value(...args) as Effect.Effect<unknown, unknown, unknown>);
cache.set(prop, wrapped);
return wrapped;
}
if (value !== null && typeof value === 'object') {
const wrapped = wrapWithProxy(
value as Record<string, unknown>,
runtime,
);
cache.set(prop, wrapped);
return wrapped;
}
return value;
},
}) as AsyncClient<T>;
}

export async function asyncClient<A extends Record<string, unknown>, E, R, ER>(
makeClient: Effect.Effect<A, E, R>,
layer: Layer.Layer<R, ER, never>,
): Promise<AsyncClient<A> & { dispose: () => Promise<void> }> {
const runtime = ManagedRuntime.make(layer);
const client = await runtime.runPromise(makeClient);
const proxied = wrapWithProxy(client, runtime);
const result = Object.create(proxied) as AsyncClient<A> & {
dispose: () => Promise<void>;
};
result.dispose = () => runtime.dispose();
return result;
}

1 change: 1 addition & 0 deletions packages/effect/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';