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.
Load code
Section titled “Load code”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()andconnect()throw.network: 'inherit'gives it your Worker’s network, andnetwork: { via: … }sends each request through a gateway you write. loadmakes 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, whosereasonsays 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.
Declare what a guest implements
Section titled “Declare what a guest implements”The contract is a module that both the host and its guests import. It holds only schemas.
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. Anenventry 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.
Host it
Section titled “Host it”The host implements what it offers, lists those implementations in its
Worker’s entrypoints, and loads guests as the interface:
// 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.apiis typed by the interface. Each payload is encoded by its schema and each reply is decoded by its schema, so a wrong reply is aDynamicErrorwith reasonreply, never a value of the wrong type. A declared failure arrives as its class:Refused, as you’d catch it withEffect.catchTag.plugin.verifyasks 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 reasoncontract.Store.grant(props)makes a stub of the host’s ownStoreclass for the guest’senv, with the props baked in, through workerd’sctx.exports. A Worker grants only what it lists inentrypoints.
Write a guest
Section titled “Write a guest”A guest in Effect implements the interface against the host’s own contract:
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)).
Code from a model
Section titled “Code from a model”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’s own workflows
Section titled “A guest’s own workflows”A guest can run durable workflows written in its own code. The host
declares the workflow as usual, plus a Workflows capability over it:
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:
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:
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 guest’s own Durable Object
Section titled “A guest’s own Durable Object”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.
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 thenyield* facets.delete('counter'); // its data tooexport 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); … }),}));Static files
Section titled “Static files”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:
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):
/aboutserves/about.htmlor/about/index.html, and an index redirects to its path with a slash.- ETags give 304s, and
HEADworks. - A path with no file gets the nearest
404.html, or, withnotFound: 'single-page-application',/index.html.
putAssets writes the manifest last, so a request sees either the old
files or all of the new ones.
Testing
Section titled “Testing”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.
Good to know
Section titled “Good to know”- The host checks every value; a guest’s types are a help, not a
guarantee. The checker and
verifycatch 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
statuscan read. Keep them to routing data, such as a tenant’s id. - In memory isn’t a sandbox.
Sandboxes.memoryis for unit tests. Useinfra devto see network, limits and tails as workerd applies them.
Checked live
Section titled “Checked live”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_foundfor 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.