Skip to content
Draft
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
35 changes: 33 additions & 2 deletions packages/alchemy/src/Cloudflare/Workers/InferEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ export type GetBindingType<T> =
// 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<infer Entrypoint, infer Target>
? EntrypointStub<Entrypoint, Target>
: // 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.
Expand Down Expand Up @@ -153,6 +153,37 @@ export type GetBindingType<T> =
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<typeof Api>(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<typeof Api>`); without it, the binding falls
* back to the target Worker's own default-entrypoint type: an Effect-native
* Worker's `Rpc<Shape>` wire shape, otherwise a bare `Fetcher`.
*/
export type EntrypointStub<Entrypoint, Target> = [Entrypoint] extends [
undefined,
]
? Target extends AlchemyRpc<infer Shape extends object>
? RpcWireShape<Shape> & Service
: Fetcher
: Entrypoint extends AlchemyRpc<infer Shape extends object>
? RpcWireShape<Shape> & Service
: Entrypoint extends abstract new (...args: any[]) => infer Instance
? EntrypointStubOf<Instance>
: EntrypointStubOf<Entrypoint>;

/** {@link EntrypointStub} for an entrypoint *instance* type. */
type EntrypointStubOf<Instance> = [Instance] extends [
Rpc.WorkerEntrypointBranded,
]
? Service<Extract<Instance, Rpc.WorkerEntrypointBranded>>
: // A plain method-bag shape (no `cloudflare:workers` brand).
Fetcher & { [K in keyof Instance]: Instance[K] };
Comment on lines +156 to +185

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we not already have something for this?


/**
* Cloudflare service-binding wire shape for an Effect-native Worker.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export type WorkerBindingResource =
| VectorizeIndex
| Secret
| Worker
| WorkerEntrypointBinding
| WorkerEntrypointBinding<any, any>
| WorkerLoader
| VersionMetadataBinding
// The Worker's own URL (`Worker.URL`).
Expand Down
47 changes: 37 additions & 10 deletions packages/alchemy/src/Cloudflare/Workers/WorkerEntrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Input<unknown>> | 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;
}

/**
Expand All @@ -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<typeof Api>`) 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
Expand All @@ -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<typeof Api>(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`
Expand All @@ -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<Entrypoint, Target> => {
const options =
typeof entrypointOrOptions === "string"
? { entrypoint: entrypointOrOptions }
Expand All @@ -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<any, any> =>
typeof value === "object" &&
value !== null &&
(value as { kind?: unknown }).kind === WorkerEntrypointTypeId;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,7 +35,7 @@ export default Alchemy.Stack(
const caller = yield* Cloudflare.Worker("EntrypointCaller", {
main: callerMain,
env: {
API: Cloudflare.WorkerEntrypoint(target, {
API: Cloudflare.WorkerEntrypoint<typeof Api>(target, {
entrypoint: "Api",
props: { tenant: "acme" },
}),
Expand Down
40 changes: 40 additions & 0 deletions packages/alchemy/test/types/WorkerEntrypointEnv.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, Record<string, unknown>> {
greet(name: string): Promise<string>;
/** 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<typeof Api>(target, "Api"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not Cloudflare.WorkerEntrypoint<Api>(target, "Api")

UNTYPED: Cloudflare.WorkerEntrypoint(target, "Api"),
},
});

type Env = Cloudflare.InferEnv<typeof Worker>;
declare const env: Env;

// The named entrypoint's RPC methods are typed.
export const _greeting: Promise<string> = env.API.greet("alice");
export const _count: Promise<number> = env.API.count();
// ...and it is still a service stub.
export const _fetched: Promise<Response> = env.API.fetch("https://example.com");

// Without the type argument the entry is a plain `Fetcher`.
export const _untyped: Promise<Response> = env.UNTYPED.fetch(
"https://example.com",
);
// @ts-expect-error - no entrypoint type argument, so no RPC methods
env.UNTYPED.greet("alice");
23 changes: 20 additions & 3 deletions website/src/content/docs/cloudflare/compute/workers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Api>(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

Expand Down
Loading