| 1 | --- |
| 2 | title: Local Provider |
| 3 | description: Build the local implementation of a resource — dual registration with ProviderLayer.dual and the LocalProvider runner for long-running processes. |
| 4 | --- |
| 5 | |
| 6 | During `alchemy dev`, some resources shouldn't touch the cloud at all: a |
| 7 | Worker runs in a local `workerd`, a dev server is a child process on your |
| 8 | machine. A **local provider** is the implementation that serves a resource |
| 9 | type in dev, alongside the **live provider** used by `alchemy deploy`. |
| 10 | |
| 11 | This guide is about *building* one. For the user-facing dev model — and |
| 12 | opting a resource out of local emulation with `remote()` — see |
| 13 | [Local development](/environments/local-development). If you haven't built |
| 14 | a provider before, start with |
| 15 | [Custom Provider](/infrastructure-as-code/custom-provider) — a local |
| 16 | provider is just a second implementation of the same lifecycle contract. |
| 17 | |
| 18 | ## What the engine does for you |
| 19 | |
| 20 | The engine remembers which implementation reconciled each resource. Moving |
| 21 | a resource between local and live is planned as a **replacement**, and |
| 22 | every instance is deleted by the provider that created it — so a deploy |
| 23 | right after a dev session tears the local instances down with *your* local |
| 24 | provider. You implement the lifecycle; the engine owns the switching. |
| 25 | |
| 26 | ## Register both: `ProviderLayer.dual` |
| 27 | |
| 28 | ```typescript |
| 29 | import * as ProviderLayer from "alchemy/Local/ProviderLayer"; |
| 30 | import * as Layer from "effect/Layer"; |
| 31 | |
| 32 | export const WorkerProvider = () => |
| 33 | ProviderLayer.dual(Worker, { |
| 34 | live: () => LiveWorkerProvider(), |
| 35 | local: () => |
| 36 | LocalWorkerProvider().pipe(Layer.provide(localRuntimeServices())), |
| 37 | }); |
| 38 | ``` |
| 39 | |
| 40 | `dual` registers the run's default mode eagerly and builds the other |
| 41 | **lazily** — e.g. the local provider is only constructed during a deploy |
| 42 | if it has an instance to tear down. |
| 43 | |
| 44 | ### Route deploy-time data-plane calls: `dataPlane` |
| 45 | |
| 46 | Binding clients can be invoked at deploy time — inside an `Action` body or |
| 47 | a plan-time `execute` — and their API calls must target whatever data |
| 48 | plane the bound resource actually lives on. Pass the local mode's |
| 49 | environment override (endpoint, credentials, region — the same layer the |
| 50 | local lifecycle runs under) as `dataPlane`: |
| 51 | |
| 52 | ```typescript |
| 53 | ProviderLayer.dual(Bucket, { |
| 54 | live: () => BucketProvider(), |
| 55 | local: () => provideProviderContext(BucketProvider(), flociServices()), |
| 56 | dataPlane: flociServices, |
| 57 | }); |
| 58 | ``` |
| 59 | |
| 60 | With it registered, a client bound to a local-mode resource is routed to |
| 61 | the emulator even when the ambient profile carries live credentials, while |
| 62 | a client bound to an `Alchemy.remote()` resource keeps hitting the real |
| 63 | cloud — per bound resource, within the same Action. |
| 64 | |
| 65 | ### Compose local deps inside the thunk |
| 66 | |
| 67 | Mode-specific dependency layers (`workerd`, Docker) go inside the `local` |
| 68 | thunk, never globally — that's what keeps a plain `alchemy deploy` from |
| 69 | constructing local machinery it doesn't need. |
| 70 | |
| 71 | :::note[Share dependency layers by reference] |
| 72 | Variant builds are memoized by layer identity. If several local providers |
| 73 | share a dependency layer (Cloudflare's `localRuntimeServices()`), the |
| 74 | factory must return a module-memoized reference so they all get one |
| 75 | instance per stack build. |
| 76 | ::: |
| 77 | |
| 78 | ## Long-running processes: `LocalProvider.make` |
| 79 | |
| 80 | A process-shaped local provider has a problem ordinary providers don't: |
| 81 | the "physical resource" is a running process that **state can't see** — a |
| 82 | resource can be marked `created` while its process died with the last dev |
| 83 | session. `LocalProvider.make` owns that machinery; you write how to boot |
| 84 | one instance. |
| 85 | |
| 86 | ### Declare the provider with `start` |
| 87 | |
| 88 | ```typescript |
| 89 | import * as LocalProvider from "alchemy/Local/LocalProvider"; |
| 90 | import * as Effect from "effect/Effect"; |
| 91 | |
| 92 | export const DevProviderLocal = () => |
| 93 | LocalProvider.make( |
| 94 | Dev, |
| 95 | import.meta.resolve("./Local.ts", import.meta.url), |
| 96 | Effect.gen(function* () { |
| 97 | const { spawn } = yield* CommandExecutor; |
| 98 | |
| 99 | return { |
| 100 | start: Effect.fn(function* ({ news }) { |
| 101 | const child = yield* spawn(news); |
| 102 | const url = yield* waitForUrl(child); |
| 103 | return { url }; |
| 104 | }), |
| 105 | } satisfies LocalProvider.LocalProviderSpec<Dev>; |
| 106 | }), |
| 107 | ); |
| 108 | ``` |
| 109 | |
| 110 | `start` boots one instance in the ambient `Scope` and returns Attributes at |
| 111 | *readiness* — the process keeps running after `start` returns, until the |
| 112 | runner closes the scope on restart or delete. |
| 113 | |
| 114 | ### Handle processes that die on their own |
| 115 | |
| 116 | ```diff lang="typescript" |
| 117 | start: Effect.fn(function* ({ news, invalidate }) { |
| 118 | const child = yield* spawn(news); |
| 119 | const url = yield* waitForUrl(child); |
| 120 | + yield* child.exitCode.pipe( |
| 121 | + Effect.exit, |
| 122 | + Effect.flatMap(() => invalidate), |
| 123 | + Effect.forkScoped, |
| 124 | + ); |
| 125 | return { url }; |
| 126 | }), |
| 127 | ``` |
| 128 | |
| 129 | `invalidate` drops the instance from the running registry, so the next plan |
| 130 | reports `update` and reconcile boots a fresh one. |
| 131 | |
| 132 | ### Narrow the restart surface with `resolveConfig` |
| 133 | |
| 134 | ```diff lang="typescript" |
| 135 | return { |
| 136 | + // Only these fields decide whether a running instance must restart. |
| 137 | + resolveConfig: ({ news }) => |
| 138 | + Effect.succeed({ command: news.command, env: news.env }), |
| 139 | start: Effect.fn(function* ({ config, invalidate }) { |
| 140 | - const child = yield* spawn(news); |
| 141 | + const child = yield* spawn(config); |
| 142 | // ... |
| 143 | }), |
| 144 | }; |
| 145 | ``` |
| 146 | |
| 147 | The runner canonically hashes `resolveConfig`'s result to decide |
| 148 | noop-vs-restart, and hands the same value to `start` — so "what changed?" |
| 149 | and "what starts?" can never drift. |
| 150 | |
| 151 | :::tip[Config must be plain data] |
| 152 | `resolveConfig` runs inside `diff` on every plan: keep it cheap, |
| 153 | side-effect-free, and free of closures or live objects. Derive plain |
| 154 | *descriptors* here and materialize runtime-rich structures from them inside |
| 155 | `start`. |
| 156 | ::: |
| 157 | |
| 158 | ### Clean up cross-restart state with `stop` |
| 159 | |
| 160 | ```diff lang="typescript" |
| 161 | return { |
| 162 | resolveConfig: ({ news }) => ..., |
| 163 | start: Effect.fn(function* ({ config, invalidate }) { ... }), |
| 164 | + // Delete-only: NOT called on restarts. |
| 165 | + stop: Effect.fn(function* ({ id }) { |
| 166 | + yield* stopProxy(id); |
| 167 | + }), |
| 168 | }; |
| 169 | ``` |
| 170 | |
| 171 | Anything acquired by `start` dies with the instance scope; `stop` is only |
| 172 | for state that intentionally *survives* restarts — like a URL proxy kept so |
| 173 | the address stays stable across rebuilds. |
| 174 | |
| 175 | ### What the runner generates |
| 176 | |
| 177 | - **`diff`** — running instance + equal config hash → `noop`; anything |
| 178 | else → `update`. A fresh dev session has an empty registry, so processes |
| 179 | restart even though they were previously `created`. |
| 180 | - **`reconcile`** — reuses the running instance on a hash match; otherwise |
| 181 | tears it down and forks `start` in a scope that outlives the deploy. |
| 182 | - **`delete`** — tears down only the matching `instanceId` (a replacement's |
| 183 | old-generation delete can't kill its successor), then runs `stop`. |
| 184 | - **`list`** — every running instance's Attributes. |
| 185 | |
| 186 | :::note[The dev sidecar] |
| 187 | The second argument (`serverEntryUrl`) serves the provider over RPC: during |
| 188 | `alchemy dev` the registry of running processes lives in a sidecar that |
| 189 | survives user-code hot reloads. Outside dev, the provider is constructed |
| 190 | in-process and no sidecar is spawned. |
| 191 | ::: |
| 192 | |
| 193 | ## Not everything is a process |
| 194 | |
| 195 | If the local "instance" is just an entry in a shared registry — Cloudflare's |
| 196 | local Queue is an entry in `LocalRuntimeState`, reconciled with three |
| 197 | idempotent map operations — skip `LocalProvider.make` and write a plain |
| 198 | provider registered via `dual`. |
| 199 | |
| 200 | ## Testing |
| 201 | |
| 202 | ```typescript |
| 203 | const { test } = Test.make({ providers, dev: true }); // dev-mode default |
| 204 | |
| 205 | test.provider("promotes a dev worker to a live worker", (stack) => |
| 206 | Effect.gen(function* () { |
| 207 | yield* program.pipe(stack.deploy); // local (dev default) |
| 208 | // Same props, pinned live: planned as a REPLACEMENT — the live |
| 209 | // provider creates the deployed worker, the local provider kills |
| 210 | // workerd. |
| 211 | yield* program.pipe(Alchemy.remote(), stack.deploy); |
| 212 | yield* stack.destroy(); |
| 213 | }), |
| 214 | ); |
| 215 | ``` |
| 216 | |
| 217 | `Test.make({ dev: true })` gives the file `alchemy dev` semantics, and |
| 218 | `remote()` works in tests exactly like in programs. |
| 219 | |
| 220 | ## Reference implementations |
| 221 | |
| 222 | - [`Command/Dev.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Command/Dev.ts) |
| 223 | — the smallest `LocalProvider.make` provider: spawn a command, extract |
| 224 | its URL, restart on prop changes. |
| 225 | - [`Cloudflare/Workers/LocalWorkerProvider.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts) |
| 226 | — the full-size one: bundler watch loop, `workerd` instances, a URL |
| 227 | proxy torn down in `stop`. |
| 228 | - [`Cloudflare/Queues/Queue.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Cloudflare/Queues/Queue.ts) |
| 229 | — the registry-style counter-example: a plain local provider under |
| 230 | `dual`. |
| 231 | |
| 232 | ## Where next |
| 233 | |
| 234 | - [Local development](/environments/local-development) — the user-facing |
| 235 | dev loop, and opting resources out of local emulation with `remote()`. |
| 236 | - [Custom Provider](/infrastructure-as-code/custom-provider) — the |
| 237 | lifecycle contract both implementations share. |
| 238 | - [Resource lifecycle](/infrastructure-as-code/resource-lifecycle) — |
| 239 | when each lifecycle method fires, including replacements. |
| 240 | - [Testing Providers](/testing/testing-providers) — the `test.provider` |
| 241 | harness used above. |