1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { ALCHEMY_PHASE } from "../../Phase.ts";
5import type { RuntimeContext } from "../../RuntimeContext.ts";
6import { taggedFunction } from "../../Util/effect.ts";
7import { asEffect } from "../../Util/index.ts";
8import { fromCloudflareFetcher, type Fetcher } from "../Fetcher.ts";
9import { makeRpcStub } from "./Rpc.ts";
10import { Worker, WorkerEnvironment } from "./Worker.ts";
11
12import type { WorkerLoader as _WorkerLoader } from "@cloudflare/workers-types";
13
14type WorkerLoaderTypeId = "Cloudflare.DynamicWorker";
15const WorkerLoaderTypeId: WorkerLoaderTypeId = "Cloudflare.DynamicWorker";
16
17export interface WorkerLoaderWorkerCode {
18 compatibilityDate: string;
19 compatibilityFlags?: string[];
20 allowExperimental?: boolean;
21 limits?: workerdResourceLimits;
22 mainModule: string;
23 modules: Record<string, WorkerLoaderModule | string>;
24 env?: any;
25 globalOutbound?: Fetcher | null;
26 tails?: Fetcher[];
27 streamingTails?: Fetcher[];
28}
29
30export type DynamicWorkerEntrypoint<Shape = unknown> = Fetcher & {
31 [K in keyof Shape]: Shape[K];
32};
33
34export interface WorkerStub extends Fetcher {
35 getEntrypoint<Shape = unknown>(name?: string): DynamicWorkerEntrypoint<Shape>;
36}
37
38export type WorkerLoader = {
39 Type: WorkerLoaderTypeId;
40 name: string;
41 get<Err = never, Req = never>(
42 name: string | null,
43 getCode: () =>
44 | WorkerLoaderWorkerCode
45 | Effect.Effect<WorkerLoaderWorkerCode, Err, Req>,
46 ): Effect.Effect<WorkerStub, Err, Req>;
47 load(
48 code: WorkerLoaderWorkerCode,
49 ): Effect.Effect<WorkerStub, never, RuntimeContext>;
50};
51
52/**
53 * Effect returned by `WorkerLoader(name)`.
54 *
55 * It is a real `Effect` — `yield* WorkerLoader(name)` inside a Worker init
56 * attaches the binding and resolves the runtime handle — but it also carries
57 * the `~alchemy/Kind` marker statically, so when it is declared on a Worker's
58 * `env` the binding machinery recognises it as a `worker_loader` binding
59 * (`isWorkerLoader`) instead of running it. Every env-resolution site that
60 * branches on "is this a runnable Effect?" therefore checks `~alchemy/Kind`
61 * (via `isWorkerLoader` or `isYieldableEffectLike`) before `Effect.isEffect`.
62 */
63export interface WorkerLoaderEffect extends Effect.Effect<
64 WorkerLoader,
65 never,
66 WorkerEnvironment | Worker
67> {
68 "~alchemy/Kind": WorkerLoaderTypeId;
69 "~alchemy/Name": string;
70 name: string;
71}
72
73export const isWorkerLoader = (value: unknown): value is WorkerLoader =>
74 typeof value === "object" &&
75 value !== null &&
76 "~alchemy/Kind" in value &&
77 (value as WorkerLoaderEffect)["~alchemy/Kind"] === WorkerLoaderTypeId;
78
79export interface WorkerLoaderClass extends Context.Service<
80 WorkerLoader,
81 WorkerLoader
82> {
83 (name?: string): WorkerLoaderEffect;
84 layer(loader: WorkerLoader): Layer.Layer<WorkerLoader>;
85 layer(id: string): Layer.Layer<WorkerLoader>;
86}
87
88/**
89 * Load and run ephemeral Workers at runtime from inline JavaScript
90 * modules.
91 *
92 * `WorkerLoader` registers a `worker_loader` binding on the
93 * parent Worker at deploy time. At runtime you call `.load()` with
94 * inline module source code and get back a fully typed Worker
95 * instance you can `fetch` or call RPC methods on. Each loaded
96 * Worker runs in its own isolate with full sandboxing.
97 *
98 * This is useful for evaluating user-provided code, running
99 * untrusted plugins, or dynamically generating Workers from
100 * templates.
101 *
102 *
103 * ### Creating a Loader
104 * Yield `Cloudflare.WorkerLoader(name)` in your Worker's init
105 * phase to register the binding and get back a runtime handle. The
106 * string argument becomes the binding name on the deployed Worker.
107 *
108 * **Example:** Registering a loader (effect-native Worker)
109 * ```typescript
110 * import * as Cloudflare from "alchemy/Cloudflare";
111 * import * as Effect from "effect/Effect";
112 * import { HttpServerRequest } from "effect/http/HttpServerRequest";
113 * import * as HttpServerResponse from "effect/http/HttpServerResponse";
114 * import * as HttpClientRequest from "effect/http/HttpClientRequest";
115 *
116 * export default class EvalWorker extends Cloudflare.Worker<EvalWorker>()(
117 * "EvalWorker",
118 * { main: import.meta.url },
119 * Effect.gen(function* () {
120 * // Registers the `worker_loader` binding on this Worker
121 * const loader = yield* Cloudflare.WorkerLoader("LOADER");
122 *
123 * return {
124 * fetch: Effect.gen(function* () {
125 * const request = yield* HttpServerRequest;
126 * const code = yield* request.text;
127 *
128 * // Spin up an isolated, sandboxed Worker from inline source.
129 * const worker = yield* loader.load({
130 * compatibilityDate: "2026-08-31",
131 * mainModule: "worker.js",
132 * modules: {
133 * "worker.js": `export default {
134 * async fetch(req) {
135 * const result = (0, eval)(await req.text());
136 * return new Response(String(result));
137 * }
138 * }`,
139 * },
140 * globalOutbound: null, // block outbound network access
141 * });
142 *
143 * // Call the loaded Worker over Effect-native HTTP.
144 * const response = yield* worker.fetch(
145 * HttpClientRequest.post("https://worker/").pipe(
146 * HttpClientRequest.bodyText(code),
147 * ),
148 * );
149 * return HttpServerResponse.fromClientResponse(response);
150 * }),
151 * };
152 * }),
153 * ) {}
154 * ```
155 *
156 * **Example:** Declaring on env (async Worker)
157 * ```typescript
158 * export const Worker = Cloudflare.Worker("Worker", {
159 * main: "./src/worker.ts",
160 * env: { LOADER: Cloudflare.WorkerLoader() },
161 * });
162 *
163 * export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;
164 *
165 * // worker.ts
166 * export default {
167 * async fetch(req: Request, env: WorkerEnv) {
168 * const worker = env.LOADER.load({
169 * compatibilityDate: "2026-08-31",
170 * mainModule: "worker.js",
171 * modules: { "worker.js": "export default { fetch: () => new Response('ok') }" },
172 * });
173 * return worker.getEntrypoint().fetch(req);
174 * },
175 * };
176 * ```
177 *
178 * ### Loading a Worker
179 * Call `loader.load()` with a compatibility date, a main module
180 * name, and a map of module names to source code strings. The
181 * returned instance exposes `.fetch()` for HTTP and RPC methods
182 * for named entrypoints.
183 *
184 * **Example:** Loading and calling a dynamic Worker
185 * ```typescript
186 * const worker = loader.load({
187 * compatibilityDate: "2026-08-31",
188 * mainModule: "worker.js",
189 * modules: {
190 * "worker.js": `export default {
191 * async fetch(request) {
192 * return new Response("Hello from dynamic worker!");
193 * }
194 * }`,
195 * },
196 * });
197 *
198 * const response = yield* worker.fetch(
199 * HttpClientRequest.get("https://worker/"),
200 * );
201 * ```
202 *
203 * ### Caching a Worker
204 * Call `loader.get(id, getCode)` to address a dynamic Worker by
205 * name. If an isolate with that id is already warm it is reused;
206 * `getCode` runs only on a cold start. The returned stub is a
207 * `WorkerStub`: call `.fetch()` on it directly, or
208 * `.getEntrypoint()` for a named export.
209 *
210 * **Example:** Loading a cached dynamic Worker
211 * ```typescript
212 * const worker = yield* loader.get("eval", () => ({
213 * compatibilityDate: "2026-08-31",
214 * mainModule: "worker.js",
215 * modules: {
216 * "worker.js": `export default {
217 * async fetch() {
218 * return new Response("cached");
219 * }
220 * }`,
221 * },
222 * }));
223 *
224 * const response = yield* worker.fetch(
225 * HttpClientRequest.get("https://worker/"),
226 * );
227 * ```
228 *
229 * ### Sandboxing
230 * Set `globalOutbound` to `null` to block all outbound network
231 * access from the dynamic Worker, or pass an RPC stub to intercept
232 * and proxy outbound requests.
233 *
234 * **Example:** Blocking outbound access
235 * ```typescript
236 * const worker = loader.load({
237 * compatibilityDate: "2026-08-31",
238 * mainModule: "worker.js",
239 * modules: {
240 * "worker.js": `export default {
241 * async fetch(req) {
242 * // fetch() calls from here will fail
243 * return new Response("sandboxed");
244 * }
245 * }`,
246 * },
247 * globalOutbound: null,
248 * });
249 * ```
250 *
251 * ### Named Entrypoints
252 * If the dynamic Worker exports named entrypoints, use
253 * `.getEntrypoint(name)` to get a typed stub for calling its
254 * methods.
255 *
256 * **Example:** Calling a named entrypoint
257 * ```typescript
258 * const worker = loader.load({ ... });
259 * const api = worker.getEntrypoint<{ greet: (name: string) => Effect.Effect<string> }>("api");
260 * const greeting = yield* api.greet("world");
261 * ```
262 *
263 * @resource
264 * @product Workers
265 * @category Workers & Compute
266 */
267export const WorkerLoader: WorkerLoaderClass = Object.assign(
268 taggedFunction(
269 Context.Service()("Cloudflare.WorkerLoader"),
270 (name = "LOADER") =>
271 Object.assign(
272 Effect.gen(function* () {
273 yield* (yield* Worker).bind`${name}`({
274 bindings: [{ type: "worker_loader", name }],
275 });
276
277 const loader: _WorkerLoader = yield* Effect.all([
278 WorkerEnvironment,
279 ALCHEMY_PHASE,
280 ]).pipe(
281 Effect.flatMap(([env, phase]) => {
282 if (env === undefined || phase === "plan") {
283 return Effect.succeed(undefined as any);
284 }
285 const loader = env[name];
286 if (!loader) {
287 return Effect.die(
288 new Error(`WorkerLoader '${name}' not found in env`),
289 );
290 }
291 return Effect.succeed(loader);
292 }),
293 );
294
295 return {
296 Type: WorkerLoaderTypeId,
297 name,
298 load: (options) =>
299 Effect.sync(() =>
300 wrapWorkerStub(loader.load(unwrapWorkerLoader(options))),
301 ),
302 get: <Err = never, Req = never>(
303 name: string | null,
304 getCode: () =>
305 | WorkerLoaderWorkerCode
306 | Effect.Effect<WorkerLoaderWorkerCode, Err, Req>,
307 ) =>
308 Effect.flatMap(Effect.context<Req>(), (context) =>
309 Effect.sync(() =>
310 // Native get() returns a WorkerStub, not a Fetcher. The
311 // stub's fetcher is getEntrypoint(); wrapping the stub
312 // itself as a Fetcher makes worker.fetch throw
313 // "fetcher.fetch is not a function" (#1382).
314 wrapWorkerStub(
315 loader.get(name, () =>
316 asEffect(getCode()).pipe(
317 Effect.provide(context),
318 Effect.map(unwrapWorkerLoader),
319 Effect.runPromise,
320 ),
321 ),
322 ),
323 ),
324 ),
325 } satisfies WorkerLoader;
326 }),
327 {
328 "~alchemy/Name": name,
329 "~alchemy/Kind": WorkerLoaderTypeId,
330 name,
331 },
332 ),
333 ),
334 {
335 layer: (loader: WorkerLoader) =>
336 Layer.succeed(Context.Service()("Cloudflare.WorkerLoader"), loader),
337 },
338) as any;
339
340/**
341 * Convert the Effect-level worker code into the shape the native
342 * `env.LOADER.get()` API expects, unwrapping wrapped `Fetcher`s to their raw
343 * Cloudflare fetchers. `globalOutbound: null` (disable network access for the
344 * dynamic worker) must survive as `null` — `?.raw` alone would coerce it to
345 * `undefined`, which the runtime treats as "default outbound", silently
346 * re-enabling network access for workers meant to be sandboxed (#746).
347 */
348const unwrapWorkerLoader = (loader: WorkerLoaderWorkerCode) => ({
349 ...loader,
350 globalOutbound:
351 loader.globalOutbound === null ? null : loader.globalOutbound?.raw,
352 tails: loader.tails?.map((t) => t.raw),
353 streamingTails: loader.streamingTails?.map((t) => t.raw),
354});
355
356const wrapDynamicWorkerEntrypoint = <Shape>(
357 raw: any,
358): DynamicWorkerEntrypoint<Shape> =>
359 Object.assign(makeRpcStub<any>(raw), fromCloudflareFetcher(raw));
360
361const wrapWorkerStub = (raw: any): WorkerStub => {
362 const defaultEntrypoint = fromCloudflareFetcher(raw.getEntrypoint());
363 return {
364 ...defaultEntrypoint,
365 getEntrypoint: <Shape>(name?: string) =>
366 wrapDynamicWorkerEntrypoint<Shape>(
367 name ? raw.getEntrypoint(name) : raw.getEntrypoint(),
368 ),
369 };
370};