Skip to content

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.

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.

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.

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.

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.

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.

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.

  • 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 previously created.
  • reconcile — reuses the running instance on a hash match; otherwise tears it down and forks start in a scope that outlives the deploy.
  • delete — tears down only the matching instanceId (a replacement’s old-generation delete can’t kill its successor), then runs stop.
  • list — every running instance’s Attributes.

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.

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.