| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import { ALCHEMY_PHASE } from "../../Phase.ts"; |
| 5 | import type { RuntimeContext } from "../../RuntimeContext.ts"; |
| 6 | import { taggedFunction } from "../../Util/effect.ts"; |
| 7 | import { asEffect } from "../../Util/index.ts"; |
| 8 | import { fromCloudflareFetcher, type Fetcher } from "../Fetcher.ts"; |
| 9 | import { makeRpcStub } from "./Rpc.ts"; |
| 10 | import { Worker, WorkerEnvironment } from "./Worker.ts"; |
| 11 | |
| 12 | import type { WorkerLoader as _WorkerLoader } from "@cloudflare/workers-types"; |
| 13 | |
| 14 | type WorkerLoaderTypeId = "Cloudflare.DynamicWorker"; |
| 15 | const WorkerLoaderTypeId: WorkerLoaderTypeId = "Cloudflare.DynamicWorker"; |
| 16 | |
| 17 | export 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 | |
| 30 | export type DynamicWorkerEntrypoint<Shape = unknown> = Fetcher & { |
| 31 | [K in keyof Shape]: Shape[K]; |
| 32 | }; |
| 33 | |
| 34 | export interface WorkerStub extends Fetcher { |
| 35 | getEntrypoint<Shape = unknown>(name?: string): DynamicWorkerEntrypoint<Shape>; |
| 36 | } |
| 37 | |
| 38 | export 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 | */ |
| 63 | export 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 | |
| 73 | export 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 | |
| 79 | export 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 | */ |
| 267 | export 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 | */ |
| 348 | const 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 | |
| 356 | const wrapDynamicWorkerEntrypoint = <Shape>( |
| 357 | raw: any, |
| 358 | ): DynamicWorkerEntrypoint<Shape> => |
| 359 | Object.assign(makeRpcStub<any>(raw), fromCloudflareFetcher(raw)); |
| 360 | |
| 361 | const 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 | }; |