Skip to main content
A Dynamic Worker is JavaScript that your App loads at runtime into a separate V8 isolate. The code can come from a user, a database, or a model. Use Dynamic Workers for code that you didn’t write at build time, such as user transforms, plugins, formulas, and AI-generated tools. Loaded code gets no bindings and no Secrets. It can use only what you pass to it. celld, the Judt runtime, implements this feature with the Cloudflare Worker Loader API.

Configure

Add a Worker Loader binding to the wrangler.json file:
wrangler.json
The entry accepts only the binding key. Deploy refuses limits and tails in this entry. Set them in the code object that you load instead.

Load and call code

Call env.LOADER.get with an ID and a function that returns the code. Then call an entrypoint of the loaded Worker:
The loader has the following methods:
  • env.LOADER.get(id, getCode): compiles the code at the first use of the ID in an isolate. Later calls with the same ID use the compiled code. The getCode function can be async, and it runs only to compile. An error in getCode appears at the first call.
  • env.LOADER.load(code): compiles the code at each call, with no cache.
  • stub.dispose(): releases a loaded Worker and its ID.
Put a version or a content hash in the ID that you pass to env.LOADER.get. If the code changes and the ID stays the same, then the old code continues to run.
Each node and each isolate compiles the code separately.

The code object

The following table lists the fields of the object that getCode returns, or that you pass to load: celld refuses the allowExperimental field.

Call entrypoints

A loaded Worker exposes the following entrypoints:
  • worker.getEntrypoint() returns a Fetcher for the default export.
  • worker.getEntrypoint("NAME", { props, limits }) returns an RPC stub for a WorkerEntrypoint export named NAME. The props value becomes this.ctx.props in the loaded code.
  • worker.getDurableObjectClass("NAME", { props }) returns a class that you can run as a facet. For more information, see Durable Object facets.
A loaded Worker can’t load other Workers.

Run user code under the CPU limit

Top-level code of a module can run outside the limits.cpuMs limit. A loop at the top level of user code can then use a CPU core with no limit. To prevent this, don’t import user code statically. Load your own runner module with the user code, and import the user code dynamically inside the limited call:
runner.js
We recommend the following practices for user code:
  • Define a clear contract, for example “export default a function that takes the input and returns JSON.”
  • Put each call in a try...catch block. User code throws, times out, and reaches limits. Return a 4xx response for these errors, not a 500.
  • Set your own timeouts. limits.cpuMs stops a CPU loop, but not a wait on a timer. To stop a fetch() to a loaded Worker, pass signal: AbortSignal.timeout(ms). To stop an RPC call, race it against your own timer. The loaded code continues to run until it ends.
  • Before you save user code, do a test load and a test call. Return syntax errors to the user.

Give the code capabilities

The loaded isolate has no env.DB, no env.KV, and no Secrets. To give access to one of them, export a WorkerEntrypoint class from your App, and pass a stub with props in the env field of the code object. Each call from the loaded code comes back into your code, where you can check it:
index.js
In the code object, pass the stub. The ctx value is the ctx argument of the fetch handler, or this.ctx in a Durable Object:
Don’t pass raw bindings or Secret values to loaded code. Pass small capabilities with limits instead.

Control network access

The globalOutbound field sets what the loaded code can reach:
  • null: no network. fetch() and connect() throw. Use this value for untrusted code.
  • Omitted: the loaded code gets the network access of your App. Use this option only for trusted code.
  • A Fetcher: each fetch from the loaded code goes to that Fetcher, which can check it against an allowlist. For example, pass ctx.exports.Egress({ props: { tenant } }), where Egress is a WorkerEntrypoint class with a fetch(request) method. With a Fetcher, connect() and WebSockets always throw.

Read logs from loaded code

console.log calls in loaded code don’t go to the App logs. To collect them, export a tail entrypoint and pass it in the tails field:
index.js
In the code object, pass the tail:

Limits

The following table lists the limits of Dynamic Workers: A load over the limit throws. Use stable IDs and the dispose method. Don’t call load for each request. Loaded Workers end with the isolate that loaded them. After a deploy, celld compiles them again.

Security boundary

The boundary is a separate V8 isolate in the same process. It isn’t a virtual machine. Always set limits. For untrusted code, also set globalOutbound to null.

Differences from Cloudflare

Keep the following celld behavior in mind when you port Dynamic Worker code from Cloudflare:
  • The worker_loaders entry accepts only binding. Set limits and tails in the code object.
  • celld refuses the allowExperimental field.
  • Top-level module code can run outside limits.cpuMs. Import user code dynamically inside the limited call.
  • console.log in loaded code doesn’t reach the App logs. Use a tail.
  • A process holds a maximum of 256 live Dynamic Workers.