Skip to content

Dynamic Workers

A Dynamic Worker is a Worker your Worker makes while it runs, from code it hands over: code a model wrote, a plugin, a user’s script. Each runs in a sandbox of its own and reaches only what you give it. It has no network unless you say so.

This page starts with the plain version, then builds up the typed one. Its samples come from the fixtures in packages/infra/test/fixtures (dynamic, dynamic-typed, dynamic-checked, dynamic-workflows, dynamic-facets), each a small app that infra dev and the tests run. In the typed version you declare what a guest implements and what it’s given, as Effect schemas. A guest written in Effect is checked against those schemas by the type checker before it runs. Every value that crosses is checked again as it runs, whether the guest was written in Effect or plain JavaScript.

import { Dynamic } from '@forge-effect/infra';
class Sandboxes extends Dynamic.Loader<Sandboxes>()('sandboxes') {}
const sandboxes = yield* Sandboxes;
const worker = sandboxes.load({
modules: { 'main.js': 'export default { fetch: (request, env) => new Response(env.GREETING) }' },
env: { GREETING: 'hello from the parent' },
});
const response = yield* worker.fetch('https://sandbox/');
  • No network unless you say. Its fetch() and connect() throw. network: 'inherit' gives it your Worker’s network, and network: { via: … } sends each request through a gateway you write.
  • load makes a fresh Worker each time. get(id, code) keeps one by an id and asks for its code only when an isolate needs it, so give each version of the code its own id (tool:v3, or a hash).
  • limits: { cpuMs, subRequests } caps each call. worker.limited(…) sets lower limits for some calls only.
  • worker.call(method, args) calls an RPC method of its default export, unchecked. The typed version below is the one to use for anything you rely on.
  • If it throws, you get a DynamicError, whose reason says where it went wrong (load, threw, reply, contract, …).

Bind the loader like any resource: Worker.make('host', { fetch }).bind(Sandboxes). infra dev runs Dynamic Workers locally, in workerd. They need the Workers Paid plan: 1,000 Workers a month are included, then $0.002 for each Worker a day.

The contract is a module that both the host and its guests import. It holds only schemas.

plugin.contract.ts
import { Schema } from 'effect';
import { Capability, Gateway, Interface, Tail } from '@forge-effect/infra/dynamic';
/** A tenant's keys and values, which a guest reads and writes through the host. */
export class Store extends Capability<Store>()('store', {
get: { payload: { key: Schema.String }, success: Schema.NullOr(Schema.String) },
put: { payload: { key: Schema.String, value: Schema.String } },
}, { props: { tenant: Schema.String } }) {}
export class Refused extends Schema.TaggedError<Refused>()('Refused', { reason: Schema.String }) {}
/** What a guest implements. */
export class Plugin extends Interface<Plugin>()('plugin', {
transform: { payload: { input: Schema.String }, success: Schema.Struct({ output: Schema.String, count: Schema.Finite }), error: Refused },
}, { env: { STORE: Store, GREETING: Schema.String } }) {}
/** Where a guest's fetch() goes. */
export class Egress extends Gateway<Egress>()('egress', { props: { tenant: Schema.String } }) {}
/** Told what a guest logged. */
export class Logs extends Tail<Logs>()('logs', { props: { guest: Schema.String } }) {}
  • An interface lists the guest’s methods and its env. An env entry is either a value with a schema, or a capability.
  • A capability has methods the host implements and the guest calls. Its props are set by the host when it grants one, such as the tenant it’s scoped to. The guest can neither see them nor change them.
  • A gateway sees each request the guest’s fetch() makes, with its props. It can forward the request, add a credential the guest never sees, or refuse it.
  • A Tail Worker is told each call’s logs and exceptions.

The host implements what it offers, lists those implementations in its Worker’s entrypoints, and loads guests as the interface:

host.ts
// readFor, writeFor and record are yours: a KV namespace, a table, a log.
export const store = Store.implement(({ tenant }) => ({
get: ({ key }) => readFor(tenant, key),
put: ({ key, value }) => writeFor(tenant, key, value),
}));
export const egress = Egress.implement((request, { tenant }) =>
new URL(request.url).hostname === 'api.example.com'
? Effect.promise(() => globalThis.fetch(request))
: Effect.succeed(new Response(`not for ${tenant}`, { status: 403 })),
);
export const logs = Logs.implement((events, { guest }) => record(guest, events));
const fetch = Effect.gen(function* () {
const plugin = yield* (yield* Sandboxes).load(Plugin, {
modules: { 'guest.js': bundle },
env: { STORE: yield* Store.grant({ tenant }), GREETING: 'hello' },
network: { via: yield* Egress.grant({ tenant }) },
tails: [yield* Logs.grant({ guest: tenant })],
limits: { cpuMs: 50 },
});
yield* plugin.verify; // it implements this version of Plugin
const out = yield* plugin.api.transform({ input }); // its reply decoded by Plugin's schema
return Response.json(out);
});
export default Worker.make('host', { fetch, entrypoints: [store, egress, logs] }).bind(Sandboxes);
  • plugin.api is typed by the interface. Each payload is encoded by its schema and each reply is decoded by its schema, so a wrong reply is a DynamicError with reason reply, never a value of the wrong type. A declared failure arrives as its class: Refused, as you’d catch it with Effect.catchTag.
  • plugin.verify asks the guest which interface it implements, and which version. The version is a fingerprint of the interface’s schemas. A guest built against another version, or plain JavaScript, which answers no handshake, fails with reason contract.
  • Store.grant(props) makes a stub of the host’s own Store class for the guest’s env, with the props baked in, through workerd’s ctx.exports. A Worker grants only what it lists in entrypoints.

A guest in Effect implements the interface against the host’s own contract:

guest.ts
import { Effect } from 'effect';
import { Guest } from '@forge-effect/infra/dynamic-guest';
import { Plugin, Refused, Store } from 'host:plugin';
export default Guest.serve(Plugin.implement({
transform: ({ input }) =>
Effect.gen(function* () {
if (input === '') {
return yield* new Refused({ reason: 'nothing to transform' });
}
const store = yield* Store;
const { GREETING } = yield* Plugin.Env;
const count = Number((yield* store.get({ key: 'count' })) ?? '0') + 1;
yield* store.put({ key: 'count', value: String(count) });
return { output: `${GREETING}: ${input.toUpperCase()}`, count };
}).pipe(Effect.catchTag('DynamicError', Effect.die)),
}));

yield* Store gives it the capability’s client, each method an Effect. yield* Plugin.Env gives it the env’s values, decoded. A guest in plain JavaScript is a WorkerEntrypoint, and is checked by the schemas alone. It sends values as their JSON form, and throws a declared failure as new Error('forge-effect/failure:' + JSON.stringify(failure)).

To have a model write a guest, give it the interface, check what it writes before it runs, and show it the diagnostics when it’s wrong.

1. Tell it what to implement. Dynamic.declarations(Plugin) prints the interface as TypeScript, from the very schemas the host checks with, with descriptions as doc comments. The default style is 'plain', the WorkerEntrypoint plain JavaScript implements. style: 'effect' gives the implement call against host:<contract>.

2. Check it in a container. Dynamic.Checker runs the type checker and the bundler in a container, one per tenant. A container is the smallest unit of trust, so tenants never share one. It has no Internet, and it sleeps once idle. Its image holds your contracts, which a guest imports as host:<name>, so the guest is checked against the same schemas your Worker decodes with:

class Checker extends Dynamic.Checker<Checker>()('checker', {
contracts: { plugin: new URL('./plugin.contract.ts', import.meta.url) },
}) {}
const checked = yield* Checker.check(tenant, { files: { 'guest.ts': source } });
if (!checked.ok) {
return checked.diagnostics; // back to the model
}
// checked.bundle: one module, for `modules: { 'guest.js': bundle }`

A diagnostic says where it was found and what’s wrong:

{ "stage": "typecheck", "severity": "error", "file": "guest.ts", "line": 9, "column": 9, "code": "TS2375",
"message": "Type '{ output: number; count: number; }' is not assignable to type '{ readonly output: string; readonly count: number; }'. …" }

The checker accepts only source files, up to 64 files and a megabyte. A guest may import only its own files, effect, infra’s guest library and your contracts. Anything else, such as node:fs or a path outside its files, is refused. The host’s Worker hosts the checker’s objects (objects: [Checker.object]) and binds Checker. infra ship builds the image: TypeScript 7 (the native compiler, with no install scripts), esbuild and Effect, pinned. Checked live, a check takes 1.3 to 4.5 seconds, a new tenant’s cold container included.

3. Try it before trusting it. Dynamic.check(Plugin, guest) is a dry run. It verifies the guest, then calls each method with payloads generated from the method’s schema, plus any examples you give, and decodes each reply. A declared failure is a fine answer. A reply that doesn’t decode, a defect, or a call over its timeout is a diagnostic, with the method and the payload that caused it. Grant the dry run a throwaway tenant’s capabilities.

const report = yield* Dynamic.check(Plugin, guest, { runs: 10, examples: { transform: [{ input: '' }] } });
// { ok: false, calls: 11, diagnostics: [{ stage: 'reply', method: 'transform', input: '{"input":"a"}', message: '…' }] }

A guest can run durable workflows written in its own code. The host declares the workflow as usual, plus a Workflows capability over it:

contract
import { Make } from '@forge-effect/infra/workflow';
import { Workflows } from '@forge-effect/infra/dynamic';
export class Sum extends Make<Sum>()('sum', {
payload: { numbers: Schema.Array(Schema.Finite) },
success: Schema.Struct({ total: Schema.Finite }),
events: { approve: Approve },
}) {}
export class Sums extends Workflows<Sums>()('sums', Sum, { props: { tenant: Schema.String } }) {}

A contract imports from @forge-effect/infra/dynamic and @forge-effect/infra/workflow, which a guest’s bundle and the checker have, not from @forge-effect/infra. The host lists Sums.hosted in its Worker’s entrypoints. Its body of the workflow is Sums.dispatch, which loads the guest an instance belongs to each time the engine runs it:

host
export const sum = Sums.dispatch(({ tenant }) => guestOf(tenant));
Worker.make('host', { fetch, entrypoints: [Sums.hosted], workflows: [sum] }).bind(Sandboxes, Sum);

The guest starts and steers instances through yield* Sums (create, createBatch, status, send, pause, resume, restart, terminate), each call typed by the workflow’s schemas. It exports its body as the workflow’s class name plus Workflow. Its steps can use the capabilities in its env:

guest
export const SumWorkflow = Guest.workflow(Sum.implement(({ numbers }) => …), { env: Jobs });

Each instance carries the props it was created under, in Cloudflare’s own envelope (@cloudflare/dynamic-workflows’), and its id is prefixed by them. A guest names job-1, and no other tenant can reach it. The guest’s output has to decode as the workflow’s, or the instance fails for good.

A facet is a Durable Object of a guest’s class running inside one of yours, with a SQLite database of its own. Your object’s code can’t read the facet’s database, and the facet can’t read yours.

host's object
const facets = yield* Dynamic.Facets;
const counter = yield* facets.get(Counter, 'counter', Effect.succeed({ worker: (yield* Sandboxes).get(codeId, code), className: 'CounterObject' }));
yield* counter.verify;
const total = yield* counter.api.increment({ by: 1 });
yield* facets.abort('counter'); // stopped, its data kept: the next get starts it on the code given then
yield* facets.delete('counter'); // its data too
guest
export const CounterObject = Guest.object(Counter.implement({
increment: ({ by }) => Effect.gen(function* () {
const facet = yield* Facet; // its id, `sql`, and `kv`: its own storage
yield* facet.sql('INSERT INTO counts (n) VALUES (?)', by);
…
}),
}));

Dynamic Workers have no assets of their own. A host keeps a project’s files in R2 and grants the guest a gateway that serves them, as its ASSETS:

host
class Site extends Dynamic.Gateway<Site>()('site', { props: { project: Schema.String } }) {}
export const site = Site.implement(Dynamic.serveAssets(Files, ({ project }) => `sites/${project}/`));
yield* Dynamic.putAssets(yield* Files, 'sites/acme/', { '/index.html': html, '/app.js': js });
const guest = sandboxes.get(id, Effect.succeed({ modules, env: { ASSETS: yield* Site.grant({ project: 'acme' }) } }));

The guest answers its own routes and hands the rest on with env.ASSETS.fetch(request):

  • /about serves /about.html or /about/index.html, and an index redirects to its path with a slash.
  • ETags give 304s, and HEAD works.
  • A path with no file gets the nearest 404.html, or, with notFound: 'single-page-application', /index.html.

putAssets writes the manifest last, so a request sees either the old files or all of the new ones.

Sandboxes.memory(guests) runs guests in your test’s own process. It picks a guest for each code object by its main module, or by a function of its code. Values are cloned as they cross, so typed loads, handshakes, calls and failures behave as they do in workerd. When a test has no workerd, a Worker’s grants are loopbacks in memory that run its entrypoints with the test’s services:

const sandboxes = Sandboxes.memory({ 'guest.js': Plugin.implement({ transform: … }) });
const response = yield* Worker.handle(host, request).pipe(Effect.provide(sandboxes));

The memory loader doesn’t sandbox guests, cut off their network, call their Tail Workers or enforce their limits. infra dev does all of that, in workerd.

  • The host checks every value; a guest’s types are a help, not a guarantee. The checker and verify catch a guest that was written wrong. The schemas catch one that lies.
  • Ids are the cache. With get(id, code), the same id must always have the same code. Name ids by a version or by the code’s hash.
  • Don’t put secrets in props that travel. A workflow instance’s props are kept in its payload, which status can read. Keep them to routing data, such as a tenant’s id.
  • In memory isn’t a sandbox. Sandboxes.memory is for unit tests. Use infra dev to see network, limits and tails as workerd applies them.

Checked on Cloudflare on October 17, with the fixtures made and torn down:

  • Typed calls, with a capability scoped to its tenant, and a declared failure decoded as its class.
  • The gateway let example.com through and refused the rest. The Tail Worker heard the guest’s logs.
  • A dry run, and limits.
  • Per-tenant checker containers, as above.
  • A guest’s own workflow: started, waited on, and completed from the host. Another tenant got instance.not_found for the same id.
  • Facets, each with its own SQLite: upgraded with their data kept, and deleted.
  • Assets, with redirects, a 404 page and 304s.
  • Workflows: the steps, sleeps and events a guest’s workflow is made of.
  • Durable Objects: the objects facets run inside.