1import {
2 DurableObject,
3 WorkerEntrypoint,
4 WorkflowEntrypoint,
5} from "cloudflare:workers";
6
7import { INIT_PATH } from "./constants.shared.ts";
8import type { Env as WrapperEnv } from "./env.worker.ts";
9import { stripInternalEnv } from "./env.worker.ts";
10import { getWorkerEntryExport } from "./module-runner.worker.ts";
11
12/**
13 * Constructor interface for `WorkerEntrypoint` class.
14 * @template T - The `env` type
15 */
16interface WorkerEntrypointConstructor<T = Cloudflare.Env> {
17 new (
18 ...args: ConstructorParameters<typeof WorkerEntrypoint<T>>
19 ): WorkerEntrypoint<T>;
20}
21
22/**
23 * Constructor interface for `DurableObject` class.
24 * @template T - The `env` type
25 */
26interface DurableObjectConstructor<T = Cloudflare.Env> {
27 new (
28 ...args: ConstructorParameters<typeof DurableObject<T>>
29 ): DurableObject<T>;
30}
31
32/**
33 * Constructor interface for `WorkflowEntrypoint` class.
34 * @template T - The `env` type
35 */
36interface WorkflowEntrypointConstructor<T = Cloudflare.Env> {
37 new (
38 ...args: ConstructorParameters<typeof WorkflowEntrypoint<T>>
39 ): WorkflowEntrypoint<T>;
40}
41
42/** Keys that should be ignored during RPC property access */
43export const IGNORED_KEYS = ["self"] as const;
44
45/** Available methods for `WorkerEntrypoint` class */
46export const WORKER_ENTRYPOINT_KEYS = [
47 "connect",
48 "email",
49 "fetch",
50 "queue",
51 "tail",
52 "tailStream",
53 "test",
54 "trace",
55 "scheduled",
56] as const;
57
58/** Available methods for `DurableObject` class */
59export const DURABLE_OBJECT_KEYS = [
60 "alarm",
61 "connect",
62 "fetch",
63 "webSocketClose",
64 "webSocketError",
65 "webSocketMessage",
66] as const;
67
68/** Available methods for `WorkflowEntrypoint` classes */
69export const WORKFLOW_ENTRYPOINT_KEYS = ["run"] as const;
70
71/**
72 * Creates a proxy wrapper for `WorkerEntrypoint` classes that enables RPC functionality.
73 * The wrapper intercepts property access and delegates to the user code, handling both direct method calls and RPC property access.
74 * @param exportName - The name of the `WorkerEntrypoint` export to wrap
75 * @returns A `WorkerEntrypoint` constructor that acts as a proxy to the user code
76 */
77export function createWorkerEntrypointWrapper(
78 exportName: string,
79): WorkerEntrypointConstructor<WrapperEnv> {
80 class Wrapper extends WorkerEntrypoint<WrapperEnv> {
81 constructor(ctx: ExecutionContext, env: WrapperEnv) {
82 super(ctx, env);
83
84 return new Proxy(this, {
85 get(target, key, receiver) {
86 const value = Reflect.get(target, key, receiver);
87
88 if (value !== undefined) {
89 return value;
90 }
91
92 if (
93 typeof key === "symbol" ||
94 (IGNORED_KEYS as ReadonlyArray<string>).includes(key) ||
95 // The class methods are accessed to determine the type of the export.
96 // We should therefore avoid proxying `DurableObject` methods on the `WorkerEntrypoint` class.
97 (DURABLE_OBJECT_KEYS as ReadonlyArray<string>).includes(key)
98 ) {
99 return;
100 }
101
102 const property = getWorkerEntrypointRpcProperty.call(
103 receiver,
104 exportName,
105 key,
106 );
107
108 return getRpcPropertyCallableThenable(key, property);
109 },
110 });
111 }
112 }
113
114 for (const key of WORKER_ENTRYPOINT_KEYS) {
115 Wrapper.prototype[key] = async function (arg) {
116 const proxy = async () => {
117 const exportValue = await getWorkerEntryExport(this.env, exportName);
118 const userEnv = stripInternalEnv(this.env);
119
120 if (typeof exportValue === "object" && exportValue !== null) {
121 // The export is an `ExportedHandler`
122 const maybeFn = (exportValue as Record<string, unknown>)[key];
123
124 if (typeof maybeFn !== "function") {
125 throw new TypeError(
126 `Expected "${exportName}" export of "${this.env.__DISTILLED_ENVIRONMENT__.entryName}" to define a \`${key}()\` function.`,
127 );
128 }
129
130 return maybeFn.call(exportValue, arg, userEnv, this.ctx);
131 } else if (typeof exportValue === "function") {
132 // The export is a `WorkerEntrypoint`
133 const ctor = exportValue as WorkerEntrypointConstructor;
134 const instance = new ctor(this.ctx, userEnv);
135
136 if (!(instance instanceof WorkerEntrypoint)) {
137 throw new TypeError(
138 `Expected "${exportName}" export of "${this.env.__DISTILLED_ENVIRONMENT__.entryName}" to be a subclass of \`WorkerEntrypoint\`.`,
139 );
140 }
141
142 const maybeFn = instance[key];
143
144 if (typeof maybeFn !== "function") {
145 throw new TypeError(
146 `Expected "${exportName}" export of "${this.env.__DISTILLED_ENVIRONMENT__.entryName}" to define a \`${key}()\` method.`,
147 );
148 }
149
150 return (maybeFn as (arg: unknown) => unknown).call(instance, arg);
151 } else {
152 throw new TypeError(
153 `Expected "${exportName}" export of "${this.env.__DISTILLED_ENVIRONMENT__.entryName}" to be an object or a class.`,
154 );
155 }
156 };
157
158 if (key === "fetch") {
159 const request = arg as Request;
160 const url = new URL(request.url);
161 // Initialize the module runner
162 if (url.pathname === INIT_PATH) {
163 const stub = this.env.__DISTILLED_MODULE_RUNNER__.get("singleton");
164 return stub.fetch(request);
165 }
166 try {
167 return await proxy();
168 } catch (error) {
169 // oxlint-disable no-console
170 console.error("Error calling fetch", error);
171 throw error;
172 }
173 }
174 return await proxy();
175 };
176 }
177
178 return Wrapper;
179}
180
181/**
182 * Retrieves an RPC property from a `WorkerEntrypoint` export, creating an instance and returning the bound method or property value.
183 * @param exportName - The name of the `WorkerEntrypoint` export
184 * @param key - The property key to access on the `WorkerEntrypoint` instance
185 * @returns The property value, with methods bound to the instance
186 * @throws TypeError if the export is not a `WorkerEntrypoint` subclass
187 */
188async function getWorkerEntrypointRpcProperty(
189 this: WorkerEntrypoint<WrapperEnv>,
190 exportName: string,
191 key: string,
192): Promise<unknown> {
193 const ctor = (await getWorkerEntryExport(
194 this.env,
195 exportName,
196 )) as WorkerEntrypointConstructor;
197 const userEnv = stripInternalEnv(this.env);
198 const expectedWorkerEntrypointMessage = `Expected "${exportName}" export of "${this.env.__DISTILLED_ENVIRONMENT__.entryName}" to be a subclass of \`WorkerEntrypoint\` for RPC.`;
199
200 if (typeof ctor !== "function") {
201 throw new TypeError(expectedWorkerEntrypointMessage);
202 }
203
204 const instance = new ctor(this.ctx, userEnv);
205
206 if (!(instance instanceof WorkerEntrypoint)) {
207 throw new TypeError(expectedWorkerEntrypointMessage);
208 }
209
210 const value = getRpcProperty(ctor, instance, key);
211
212 if (typeof value === "function") {
213 return value.bind(instance);
214 }
215
216 return value;
217}
218
219/** Symbol key for storing the `DurableObject` instance */
220const kInstance = Symbol("kInstance");
221/** Symbol key for the instance initialization method */
222const kEnsureInstance = Symbol("kEnsureInstance");
223
224/**
225 * Container for a `DurableObject` constructor and its instance.
226 */
227interface DurableObjectInstance {
228 ctor: DurableObjectConstructor;
229 instance: DurableObject;
230}
231
232/**
233 * Extended `DurableObject` interface that includes instance management methods.
234 */
235interface DurableObjectWrapper extends DurableObject<WrapperEnv> {
236 [kInstance]?: DurableObjectInstance;
237 [kEnsureInstance](): Promise<DurableObjectInstance>;
238}
239
240/**
241 * Retrieves an RPC property from a `DurableObject` export, ensuring an instance is properly initialized and returning the bound method or property value.
242 * @param exportName - The name of the `DurableObject` export
243 * @param key - The property key to access on the `DurableObject` instance
244 * @returns The property value, with methods bound to the instance
245 * @throws TypeError if the export is not a `DurableObject` subclass
246 */
247async function getDurableObjectRpcProperty(
248 this: DurableObjectWrapper,
249 exportName: string,
250 key: string,
251): Promise<unknown> {
252 const { ctor, instance } = await this[kEnsureInstance]();
253
254 if (!(instance instanceof DurableObject)) {
255 throw new TypeError(
256 `Expected "${exportName}" export of "${this.env.__DISTILLED_ENVIRONMENT__.entryName}" to be a subclass of \`DurableObject\` for RPC.`,
257 );
258 }
259
260 const value = getRpcProperty(ctor, instance, key);
261
262 if (typeof value === "function") {
263 return value.bind(instance);
264 }
265
266 return value;
267}
268
269/**
270 * Creates a proxy wrapper for `DurableObject` classes that enables RPC functionality.
271 * The wrapper manages instance lifecycle and delegates method calls to the user code, handling both direct method calls and RPC property access.
272 * @param exportName - The name of the `DurableObject` export to wrap
273 * @returns A `DurableObject` constructor that acts as a proxy to the user code
274 */
275export function createDurableObjectWrapper(
276 exportName: string,
277): DurableObjectConstructor<WrapperEnv> {
278 class Wrapper
279 extends DurableObject<WrapperEnv>
280 implements DurableObjectWrapper
281 {
282 [kInstance]?: DurableObjectInstance;
283
284 constructor(ctx: DurableObjectState, env: WrapperEnv) {
285 super(ctx, env);
286
287 return new Proxy(this, {
288 get(target, key, receiver) {
289 const value = Reflect.get(target, key, receiver);
290
291 if (value !== undefined) {
292 return value;
293 }
294
295 if (
296 typeof key === "symbol" ||
297 (IGNORED_KEYS as ReadonlyArray<string>).includes(key) ||
298 // The class methods are accessed to determine the type of the export.
299 // We should therefore avoid proxying `WorkerEntrypoint` methods on the `DurableObject` class.
300 (WORKER_ENTRYPOINT_KEYS as ReadonlyArray<string>).includes(key)
301 ) {
302 return;
303 }
304
305 const property = getDurableObjectRpcProperty.call(
306 receiver,
307 exportName,
308 key,
309 );
310
311 return getRpcPropertyCallableThenable(key, property);
312 },
313 });
314 }
315
316 async [kEnsureInstance]() {
317 const ctor = (await getWorkerEntryExport(
318 this.env,
319 exportName,
320 )) as DurableObjectConstructor;
321
322 if (typeof ctor !== "function") {
323 throw new TypeError(
324 `Expected "${exportName}" export of "${this.env.__DISTILLED_ENVIRONMENT__.entryName}" to be a subclass of \`DurableObject\`.`,
325 );
326 }
327
328 // We reuse the same instance unless the constructor changes
329 if (!this[kInstance] || this[kInstance].ctor !== ctor) {
330 const userEnv = stripInternalEnv(this.env);
331 const instance = new ctor(this.ctx, userEnv);
332
333 this[kInstance] = { ctor, instance };
334
335 // Wait for `blockConcurrencyWhile()`s in the constructor to complete
336 await this.ctx.blockConcurrencyWhile(async () => {});
337 }
338
339 return this[kInstance];
340 }
341 }
342
343 for (const key of DURABLE_OBJECT_KEYS) {
344 Wrapper.prototype[key] = async function (...args: Array<unknown>) {
345 const { instance } = await this[kEnsureInstance]();
346 const maybeFn = instance[key];
347
348 if (typeof maybeFn !== "function") {
349 throw new TypeError(
350 `Expected "${exportName}" export of "${this.env.__DISTILLED_ENVIRONMENT__.entryName}" to define a \`${key}()\` function.`,
351 );
352 }
353
354 // eslint-disable-next-line @typescript-eslint/no-explicit-any
355 return (maybeFn as (...args: Array<unknown>) => any).apply(
356 instance,
357 args,
358 );
359 };
360 }
361
362 return Wrapper;
363}
364
365/**
366 * Creates a proxy wrapper for `WorkflowEntrypoint` classes.
367 * The wrapper delegates method calls to the user code.
368 * @param exportName - The name of the `WorkflowEntrypoint` export to wrap
369 * @returns A `WorkflowEntrypoint` constructor that acts as a proxy to the user code
370 */
371export function createWorkflowEntrypointWrapper(
372 exportName: string,
373): WorkflowEntrypointConstructor<WrapperEnv> {
374 class Wrapper extends WorkflowEntrypoint<WrapperEnv> {}
375
376 for (const key of WORKFLOW_ENTRYPOINT_KEYS) {
377 Wrapper.prototype[key] = async function (...args: Array<unknown>) {
378 const ctor = (await getWorkerEntryExport(
379 this.env,
380 exportName,
381 )) as WorkflowEntrypointConstructor;
382 const userEnv = stripInternalEnv(this.env);
383 const instance = new ctor(this.ctx, userEnv);
384
385 if (!(instance instanceof WorkflowEntrypoint)) {
386 throw new TypeError(
387 `Expected "${exportName}" export of "${this.env.__DISTILLED_ENVIRONMENT__.entryName}" to be a subclass of \`WorkflowEntrypoint\`.`,
388 );
389 }
390
391 const maybeFn = instance[key];
392
393 if (typeof maybeFn !== "function") {
394 throw new TypeError(
395 `Expected "${exportName}" export of "${this.env.__DISTILLED_ENVIRONMENT__.entryName}" to define a \`${key}()\` function.`,
396 );
397 }
398
399 // eslint-disable-next-line @typescript-eslint/no-explicit-any
400 return (maybeFn as (...args: Array<unknown>) => any).apply(
401 instance,
402 args,
403 );
404 };
405 }
406
407 return Wrapper;
408}
409
410/**
411 * Creates a callable thenable that is used to access the properties of an RPC target.
412 * It can be both awaited and invoked as a function.
413 * This enables RPC properties to be used both as promises and callable functions.
414 * @param key - The property key name used for error messages
415 * @param property - The promise that resolves to the property value
416 * @returns A callable thenable that implements both Promise and function interfaces
417 */
418function getRpcPropertyCallableThenable(
419 key: string,
420 property: Promise<unknown>,
421): Promise<unknown> & ((...args: Array<unknown>) => Promise<unknown>) {
422 const fn = async function (...args: Array<unknown>) {
423 const maybeFn = await property;
424
425 if (typeof maybeFn !== "function") {
426 throw new TypeError(`"${key}" is not a function.`);
427 }
428
429 return maybeFn(...args);
430 } as Promise<unknown> & ((...args: Array<unknown>) => Promise<unknown>);
431
432 fn.then = (onFulfilled, onRejected) => property.then(onFulfilled, onRejected);
433 fn.catch = (onRejected) => property.catch(onRejected);
434 fn.finally = (onFinally) => property.finally(onFinally);
435
436 return fn;
437}
438
439/**
440 * Retrieves an RPC property from a constructor prototype, ensuring it exists on the prototype rather than the instance to maintain RPC compatibility.
441 * @param ctor - The constructor function (`WorkerEntrypoint` or `DurableObject`)
442 * @param instance - The instance to bind methods to
443 * @param key - The property key to retrieve
444 * @returns The property value from the prototype
445 * @throws TypeError if the property doesn't exist on the prototype
446 */
447function getRpcProperty(
448 ctor: WorkerEntrypointConstructor | DurableObjectConstructor,
449 instance: WorkerEntrypoint | DurableObject,
450 key: string,
451): unknown {
452 const prototypeHasKey = Reflect.has(ctor.prototype, key);
453
454 if (!prototypeHasKey) {
455 const instanceHasKey = Reflect.has(instance, key);
456
457 if (instanceHasKey) {
458 throw new TypeError(
459 [
460 `The RPC receiver's prototype does not implement "${key}", but the receiver instance does.`,
461 "Only properties and methods defined on the prototype can be accessed over RPC.",
462 `Ensure properties are declared as \`get ${key}() { ... }\` instead of \`${key} = ...\`,`,
463 `and methods are declared as \`${key}() { ... }\` instead of \`${key} = () => { ... }\`.`,
464 ].join("\n"),
465 );
466 }
467
468 throw new TypeError(`The RPC receiver does not implement "${key}".`);
469 }
470
471 return Reflect.get(ctor.prototype, key, instance);
472}