diff --git a/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts b/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts index df754db55a..dd41e45ab2 100644 --- a/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts +++ b/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts @@ -58,8 +58,8 @@ export type GetBindingType = // A named-entrypoint service binding (`Cloudflare.WorkerEntrypoint`). // Tested first: the marker *contains* a Worker, so the later Worker // branches must never see it. - T extends WorkerEntrypointBinding - ? Fetcher + T extends WorkerEntrypointBinding + ? EntrypointStub : // A Container bound in `env` is a container-backed Durable Object class — // the runtime binding is the class's namespace. Must be tested BEFORE the // generic Effect unwrap: a Container declaration is itself an Effect. @@ -153,6 +153,37 @@ export type GetBindingType = string : T; +/** + * Runtime type of a `Cloudflare.WorkerEntrypoint(...)` env entry. + * + * `Entrypoint` is the class the binding names, supplied as an explicit type + * argument (`Cloudflare.WorkerEntrypoint(target, "Api")`) — the + * entrypoint name is a string at the value level, so nothing links it to the + * target module's exports on its own. With it, the entry types as that + * class's RPC surface (`Service`); without it, the binding falls + * back to the target Worker's own default-entrypoint type: an Effect-native + * Worker's `Rpc` wire shape, otherwise a bare `Fetcher`. + */ +export type EntrypointStub = [Entrypoint] extends [ + undefined, +] + ? Target extends AlchemyRpc + ? RpcWireShape & Service + : Fetcher + : Entrypoint extends AlchemyRpc + ? RpcWireShape & Service + : Entrypoint extends abstract new (...args: any[]) => infer Instance + ? EntrypointStubOf + : EntrypointStubOf; + +/** {@link EntrypointStub} for an entrypoint *instance* type. */ +type EntrypointStubOf = [Instance] extends [ + Rpc.WorkerEntrypointBranded, +] + ? Service> + : // A plain method-bag shape (no `cloudflare:workers` brand). + Fetcher & { [K in keyof Instance]: Instance[K] }; + /** * Cloudflare service-binding wire shape for an Effect-native Worker. * diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts index d572946f7e..a1af7fa1c6 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts @@ -196,7 +196,7 @@ export type WorkerBindingResource = | VectorizeIndex | Secret | Worker - | WorkerEntrypointBinding + | WorkerEntrypointBinding | WorkerLoader | VersionMetadataBinding // The Worker's own URL (`Worker.URL`). diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerEntrypoint.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerEntrypoint.ts index 172fbbca9c..54f31bdaf9 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerEntrypoint.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerEntrypoint.ts @@ -30,15 +30,24 @@ export interface WorkerEntrypointOptions { * A service binding to a specific entrypoint of another Worker — the value * form accepted in an async Worker's `env`. See {@link WorkerEntrypoint}. */ -export interface WorkerEntrypointBinding { +export interface WorkerEntrypointBinding< + Entrypoint = undefined, + Target extends Worker = Worker, +> { /** Brand discriminating entrypoint bindings in `env` classification. */ readonly kind: WorkerEntrypointTypeId; /** The target Worker resource. */ - readonly worker: Worker; + readonly worker: Target; /** Named entrypoint on the target, or `undefined` for the default. */ readonly entrypoint: string | undefined; /** `ctx.props` delivered to the target entrypoint. */ readonly props: Record> | undefined; + /** + * Phantom carrier of the target entrypoint class's type — never present + * at runtime. `InferEnv` reads it to type `env.NAME` as that class's RPC + * surface instead of a bare `Fetcher`. + */ + readonly "~alchemy/entrypoint"?: Entrypoint; } /** @@ -53,9 +62,11 @@ export interface WorkerEntrypointBinding { * * ### Binding a Named Entrypoint * The target Worker exports a `WorkerEntrypoint` class alongside its - * default handler; the consumer selects it by name. `InferEnv` types the - * binding as a `Fetcher` service stub — RPC methods are called on it - * directly. + * default handler; the consumer selects it by name. Pass the class as a + * type argument and `InferEnv` types the binding as that class's RPC + * surface (`Service`) rather than a bare `Fetcher` — the + * entrypoint name is a runtime string, so nothing links it to the target + * module's exports on its own. * * **Example:** Bind and call a named entrypoint * ```typescript @@ -73,16 +84,29 @@ export interface WorkerEntrypointBinding { * * ```typescript * // alchemy.run.ts + * import type { Api } from "./target/src/worker.ts"; + * * const target = yield* Cloudflare.Worker("Target", { main: "./target/src/worker.ts" }); * * const caller = yield* Cloudflare.Worker("Caller", { * main: "./caller/src/worker.ts", * env: { - * API: Cloudflare.WorkerEntrypoint(target, "Api"), + * API: Cloudflare.WorkerEntrypoint(target, "Api"), * }, * }); * ``` * + * ```typescript + * // caller/src/worker.ts + * import type { CallerEnv } from "../../alchemy.run.ts"; + * + * export default { + * async fetch(request: Request, env: CallerEnv) { + * return new Response(await env.API.greet("alice")); + * }, + * }; + * ``` + * * ### Delivering ctx.props * The options form attaches properties the target reads from * `this.ctx.props` — workerd's per-binding configuration channel. `Output` @@ -102,10 +126,13 @@ export interface WorkerEntrypointBinding { * @product Workers * @category Workers & Compute */ -export const WorkerEntrypoint = ( - worker: Worker, +export const WorkerEntrypoint = < + Entrypoint = undefined, + Target extends Worker = Worker, +>( + worker: Target, entrypointOrOptions?: string | WorkerEntrypointOptions, -): WorkerEntrypointBinding => { +): WorkerEntrypointBinding => { const options = typeof entrypointOrOptions === "string" ? { entrypoint: entrypointOrOptions } @@ -121,7 +148,7 @@ export const WorkerEntrypoint = ( /** Structural guard for {@link WorkerEntrypointBinding} `env` values. */ export const isWorkerEntrypoint = ( value: unknown, -): value is WorkerEntrypointBinding => +): value is WorkerEntrypointBinding => typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === WorkerEntrypointTypeId; diff --git a/packages/alchemy/test/Cloudflare/Workers/fixtures/worker-entrypoint-binding/stack.ts b/packages/alchemy/test/Cloudflare/Workers/fixtures/worker-entrypoint-binding/stack.ts index 93c32bdb8a..f3265cdc02 100644 --- a/packages/alchemy/test/Cloudflare/Workers/fixtures/worker-entrypoint-binding/stack.ts +++ b/packages/alchemy/test/Cloudflare/Workers/fixtures/worker-entrypoint-binding/stack.ts @@ -2,6 +2,7 @@ import * as Cloudflare from "@/Cloudflare"; import * as Alchemy from "@/index"; import * as Effect from "effect/Effect"; import * as pathe from "pathe"; +import type { Api } from "./entrypoint-target-worker.ts"; const targetMain = pathe.resolve( import.meta.dirname, @@ -34,7 +35,7 @@ export default Alchemy.Stack( const caller = yield* Cloudflare.Worker("EntrypointCaller", { main: callerMain, env: { - API: Cloudflare.WorkerEntrypoint(target, { + API: Cloudflare.WorkerEntrypoint(target, { entrypoint: "Api", props: { tenant: "acme" }, }), diff --git a/packages/alchemy/test/types/WorkerEntrypointEnv.ts b/packages/alchemy/test/types/WorkerEntrypointEnv.ts new file mode 100644 index 0000000000..92554a817c --- /dev/null +++ b/packages/alchemy/test/types/WorkerEntrypointEnv.ts @@ -0,0 +1,40 @@ +import * as Cloudflare from "@/Cloudflare"; +import { WorkerEntrypoint } from "cloudflare:workers"; + +// ── `Cloudflare.WorkerEntrypoint` env inference guards ───────────────────── +// +// The entrypoint name is a runtime string, so nothing links it to the target +// module's exports. The class is supplied as a type argument instead; without +// it the entry stays a bare `Fetcher` (fetch + connect only). + +declare class Api extends WorkerEntrypoint> { + greet(name: string): Promise; + /** Non-promise returns are promisified by the RPC stub type. */ + count(): number; +} + +declare const target: Cloudflare.Worker; + +export const Worker = Cloudflare.Worker("EntrypointEnvTypeProbe", { + script: "export default {}", + env: { + API: Cloudflare.WorkerEntrypoint(target, "Api"), + UNTYPED: Cloudflare.WorkerEntrypoint(target, "Api"), + }, +}); + +type Env = Cloudflare.InferEnv; +declare const env: Env; + +// The named entrypoint's RPC methods are typed. +export const _greeting: Promise = env.API.greet("alice"); +export const _count: Promise = env.API.count(); +// ...and it is still a service stub. +export const _fetched: Promise = env.API.fetch("https://example.com"); + +// Without the type argument the entry is a plain `Fetcher`. +export const _untyped: Promise = env.UNTYPED.fetch( + "https://example.com", +); +// @ts-expect-error - no entrypoint type argument, so no RPC methods +env.UNTYPED.greet("alice"); diff --git a/website/src/content/docs/cloudflare/compute/workers.mdx b/website/src/content/docs/cloudflare/compute/workers.mdx index 45652e4463..23122e648d 100644 --- a/website/src/content/docs/cloudflare/compute/workers.mdx +++ b/website/src/content/docs/cloudflare/compute/workers.mdx @@ -471,16 +471,33 @@ workerd treats every named class export of an entry module as an entrypoint. Bind one by name with `Cloudflare.WorkerEntrypoint`: ```typescript +import type { Api } from "./src/target.ts"; + const caller = yield* Cloudflare.Worker("Caller", { main: "./src/caller.ts", env: { - API: Cloudflare.WorkerEntrypoint(target, "Api"), + API: Cloudflare.WorkerEntrypoint(target, "Api"), }, }); ``` -`InferEnv` types the entry as a `Fetcher` service stub; RPC methods on -the target class are called on it directly (`env.API.greet("alice")`). +The entrypoint name is a runtime string, so nothing links it to the +target module's exports — pass the class as a type argument and +`InferEnv` types the entry as that class's RPC surface, so +`env.API.greet("alice")` is checked against the entrypoint: + +```typescript +import type { CallerEnv } from "../alchemy.run.ts"; + +export default { + async fetch(request: Request, env: CallerEnv) { + return new Response(await env.API.greet("alice")); + }, +}; +``` + +Without the type argument the entry is a bare `Fetcher` service stub +(`fetch` + `connect` only) and RPC calls do not type-check. ## Entrypoint props