Local Providers
During alchemy dev, some resources shouldn’t touch the cloud at all: a
Worker runs in a local workerd, a dev server is a child process on your
machine. A local provider is the implementation that serves a resource
type in dev, alongside the live provider used by alchemy deploy.
This guide is about building one. For the user-facing dev model — and
opting a resource out of local emulation with remote() — see
Local development. If you haven’t built
a provider before, start with
Custom Provider — a local
provider is just a second implementation of the same lifecycle contract.
What the engine does for you
Section titled “What the engine does for you”The engine remembers which implementation reconciled each resource. Moving a resource between local and live is planned as a replacement, and every instance is deleted by the provider that created it — so a deploy right after a dev session tears the local instances down with your local provider. You implement the lifecycle; the engine owns the switching.
Register both: ProviderLayer.dual
Section titled “Register both: ProviderLayer.dual”import * as ProviderLayer from "alchemy/Local/ProviderLayer";import * as Layer from "effect/Layer";
export const WorkerProvider = () => ProviderLayer.dual(Worker, { live: () => LiveWorkerProvider(), local: () => LocalWorkerProvider().pipe(Layer.provide(localRuntimeServices())), });dual registers the run’s default mode eagerly and builds the other
lazily — e.g. the local provider is only constructed during a deploy
if it has an instance to tear down.
Compose local deps inside the thunk
Section titled “Compose local deps inside the thunk”Mode-specific dependency layers (workerd, Docker) go inside the local
thunk, never globally — that’s what keeps a plain alchemy deploy from
constructing local machinery it doesn’t need.
Long-running processes: LocalProvider.make
Section titled “Long-running processes: LocalProvider.make”A process-shaped local provider has a problem ordinary providers don’t:
the “physical resource” is a running process that state can’t see — a
resource can be marked created while its process died with the last dev
session. LocalProvider.make owns that machinery; you write how to boot
one instance.
Declare the provider with start
Section titled “Declare the provider with start”import * as LocalProvider from "alchemy/Local/LocalProvider";import * as Effect from "effect/Effect";
export const DevProviderLocal = () => LocalProvider.make( Dev, import.meta.resolve("./Local.ts", import.meta.url), Effect.gen(function* () { const { spawn } = yield* CommandExecutor;
return { start: Effect.fn(function* ({ news }) { const child = yield* spawn(news); const url = yield* waitForUrl(child); return { url }; }), } satisfies LocalProvider.LocalProviderSpec<Dev>; }), );start boots one instance in the ambient Scope and returns Attributes at
readiness — the process keeps running after start returns, until the
runner closes the scope on restart or delete.
Handle processes that die on their own
Section titled “Handle processes that die on their own”start: Effect.fn(function* ({ news, invalidate }) { const child = yield* spawn(news); const url = yield* waitForUrl(child); yield* child.exitCode.pipe( Effect.exit, Effect.flatMap(() => invalidate), Effect.forkScoped, ); return { url };}),invalidate drops the instance from the running registry, so the next plan
reports update and reconcile boots a fresh one.
Narrow the restart surface with resolveConfig
Section titled “Narrow the restart surface with resolveConfig”return { // Only these fields decide whether a running instance must restart. resolveConfig: ({ news }) => Effect.succeed({ command: news.command, env: news.env }), start: Effect.fn(function* ({ config, invalidate }) { const child = yield* spawn(news); const child = yield* spawn(config); // ... }),};The runner canonically hashes resolveConfig’s result to decide
noop-vs-restart, and hands the same value to start — so “what changed?”
and “what starts?” can never drift.
Clean up cross-restart state with stop
Section titled “Clean up cross-restart state with stop”return { resolveConfig: ({ news }) => ..., start: Effect.fn(function* ({ config, invalidate }) { ... }), // Delete-only: NOT called on restarts. stop: Effect.fn(function* ({ id }) { yield* stopProxy(id); }),};Anything acquired by start dies with the instance scope; stop is only
for state that intentionally survives restarts — like a URL proxy kept so
the address stays stable across rebuilds.
What the runner generates
Section titled “What the runner generates”diff— running instance + equal config hash →noop; anything else →update. A fresh dev session has an empty registry, so processes restart even though they were previouslycreated.reconcile— reuses the running instance on a hash match; otherwise tears it down and forksstartin a scope that outlives the deploy.delete— tears down only the matchinginstanceId(a replacement’s old-generation delete can’t kill its successor), then runsstop.list— every running instance’s Attributes.
Not everything is a process
Section titled “Not everything is a process”If the local “instance” is just an entry in a shared registry — Cloudflare’s
local Queue is an entry in LocalRuntimeState, reconciled with three
idempotent map operations — skip LocalProvider.make and write a plain
provider registered via dual.
Testing
Section titled “Testing”const { test } = Test.make({ providers, dev: true }); // dev-mode default
test.provider("promotes a dev worker to a live worker", (stack) => Effect.gen(function* () { yield* program.pipe(stack.deploy); // local (dev default) // Same props, pinned live: planned as a REPLACEMENT — the live // provider creates the deployed worker, the local provider kills // workerd. yield* program.pipe(Alchemy.remote(), stack.deploy); yield* stack.destroy(); }),);Test.make({ dev: true }) gives the file alchemy dev semantics, and
remote() works in tests exactly like in programs.
Reference implementations
Section titled “Reference implementations”Command/Dev.ts— the smallestLocalProvider.makeprovider: spawn a command, extract its URL, restart on prop changes.Cloudflare/Workers/LocalWorkerProvider.ts— the full-size one: bundler watch loop,workerdinstances, a URL proxy torn down instop.Cloudflare/Queues/Queue.ts— the registry-style counter-example: a plain local provider underdual.
Where next
Section titled “Where next”- Local development — the user-facing
dev loop, and opting resources out of local emulation with
remote(). - Custom Provider — the lifecycle contract both implementations share.
- Resource lifecycle — when each lifecycle method fires, including replacements.
- Testing Providers — the
test.providerharness used above.