diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 83e2408bd64..4c2074c0966 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -114,8 +114,9 @@ that returns a Promise or an Effect, so a denied call rejects its promise like a Extension.make({ name: "web", globals: { fetch: Extension.async((url: string) => globalThis.fetch(url)) } }) ``` -A global may return a value, a Promise, or an Effect. An Effect's requirements join the runtime's environment the same -way a tool's do, so the host can provide per-execution services such as the calling session: +A global may return a value, a Promise, or an Effect. A synchronous global's Effect runs to completion before the call +returns, so the host can offer blocking calls such as `fs.readFileSync`. An Effect's requirements join the runtime's +environment the same way a tool's do, so the host can provide per-execution services such as the calling session: ```ts const web = Extension.make({ diff --git a/packages/codemode/src/extension.ts b/packages/codemode/src/extension.ts index e7a6103e7a2..ea969601146 100644 --- a/packages/codemode/src/extension.ts +++ b/packages/codemode/src/extension.ts @@ -8,7 +8,9 @@ import type { Effect } from "effect" * function inside a result is callable the same way. Extension calls are not tool calls. A global may return a value, * a Promise, or an Effect, and an Effect's requirements join the runtime's environment. * - * A global is synchronous unless it is an `async` function or is marked with `async`. The `extension.before` hook + * A global is synchronous unless it is an `async` function or is marked with `async`. A synchronous global that + * returns an Effect blocks the program until the Effect completes and returns its value, as `fs.readFileSync` does; + * an asynchronous one hands the program a promise. The `extension.before` hook * runs before the call, so a denied call never reaches the host; it fails the way the global would, by rejecting the * promise of an asynchronous global and by throwing from a synchronous one. Functions inside a global object are * called the same way, named by their path (`fs.readFile`). @@ -20,13 +22,13 @@ export type Extension> = Readon readonly globals: Globals } -/** The Effect environment required by an extension's Effect-returning functions, at the top level or one level in. */ +/** The Effect environment required by an extension's Effect-returning functions, at any depth (`fs.promises.readFile`). */ export type Services = E extends { readonly globals: infer Globals } ? GlobalServices : never type GlobalServices = Global extends (...args: never) => unknown ? Requirements : Global extends object - ? Requirements + ? GlobalServices : never type Requirements = F extends (...args: never) => infer Result ? Effect.Services : never diff --git a/packages/codemode/src/interpreter/extensions.ts b/packages/codemode/src/interpreter/extensions.ts index 87a03d07bec..9274d8c68fd 100644 --- a/packages/codemode/src/interpreter/extensions.ts +++ b/packages/codemode/src/interpreter/extensions.ts @@ -185,7 +185,8 @@ export const extensionGlobals = ( ) if (before) yield ctx.runner.blocking(before) const host = callHost(value, args) - if (host.async) return pending(settle(host.effect), site) + // A synchronous global's Effect runs to completion before the call returns, like `readFileSync`. + if (host.result === "promise") return pending(settle(host.effect), site) return (yield ctx.runner.blocking(settle(host.effect))) as Value }, ) @@ -216,20 +217,17 @@ export const extensionGlobals = ( ) } -/** - * Calls a host function and describes what the program waits on. A returned Effect or Promise settles later; a - * returned value or a throw settles now. - */ +/** Calls a host function and describes what it returned: an Effect, a Promise, or a value (or a throw) as an Effect. */ const callHost = (value: Function, args: ReadonlyArray) => { try { const result: unknown = value.apply(undefined, args) // Extension.Services adds an Effect global's requirements to R. - if (Effect.isEffect(result)) return { async: true, effect: result as Effect.Effect } + if (Effect.isEffect(result)) return { result: "effect", effect: result as Effect.Effect } if (result instanceof Promise) - return { async: true, effect: Effect.tryPromise({ try: () => result, catch: (reason) => reason }) } - return { async: false, effect: Effect.succeed(result) } + return { result: "promise", effect: Effect.tryPromise({ try: () => result, catch: (reason) => reason }) } + return { result: "value", effect: Effect.succeed(result) } } catch (reason) { - return { async: false, effect: Effect.fail(reason) } + return { result: "value", effect: Effect.fail(reason) } } } diff --git a/packages/codemode/test/extensions.test.ts b/packages/codemode/test/extensions.test.ts index 5e66727559e..236a149fcac 100644 --- a/packages/codemode/test/extensions.test.ts +++ b/packages/codemode/test/extensions.test.ts @@ -417,13 +417,18 @@ describe("Effect globals", () => { const web = Extension.make({ name: "web", globals: { - fetch: (url: string) => + fetch: Extension.async((url: string) => Effect.gen(function* () { const session = yield* Session sent.push(url) return { url, session: session.id } }), - fail: () => Effect.fail(new RangeError("denied")), + ), + fail: Extension.async(() => Effect.fail(new RangeError("denied"))), + fs: { + readSync: (path: string) => Session.use((session) => Effect.succeed(`${session.id}:${path}`)), + failSync: () => Effect.fail(new RangeError("missing")), + }, }, }) @@ -458,6 +463,18 @@ describe("Effect globals", () => { expect(result).toMatchObject({ ok: true, value: [true, "denied"] }) }) + test("a synchronous Effect global returns its value, or throws, before the next statement", async () => { + const result = await Effect.runPromise( + CodeMode.make({ extensions: [web] }) + .execute( + `const text = fs.readSync("a") + try { fs.failSync() } catch (e) { return [text, e instanceof RangeError, e.message] }`, + ) + .pipe(Effect.provideService(Session, { id: "ses_1" })), + ) + expect(result).toMatchObject({ ok: true, value: ["ses_1:a", true, "missing"] }) + }) + test("a denying before hook stops the Effect before it runs", async () => { sent.length = 0 const runtime = CodeMode.make({