| 1 | --- |
| 2 | title: Custom Runtime |
| 3 | description: Implement your own Runtime resource on the Platform type — a Provider that provisions the compute and bundles the runtime Effect. |
| 4 | --- |
| 5 | |
| 6 | Worker, Lambda, and Container cover the common cases. This guide is |
| 7 | for bringing the [Runtime](/infrastructure-as-effects/runtime) |
| 8 | model to a new compute target. A custom runtime is a |
| 9 | [Provider](/infrastructure-as-code/provider) with two jobs: provision |
| 10 | the compute infrastructure, and bundle + upload the runtime Effect. |
| 11 | |
| 12 | The running reference is `AWS.ECS.Task` |
| 13 | ([`AWS/ECS/Task.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/AWS/ECS/Task.ts)) |
| 14 | — it bundles an inline Effect program, builds a Docker image, and |
| 15 | registers a Fargate task definition. Every built-in runtime — |
| 16 | `Cloudflare.Worker`, `AWS.Lambda.Function`, `AWS.EC2.Instance` — |
| 17 | follows the same two-part shape. |
| 18 | |
| 19 | ## The two halves |
| 20 | |
| 21 | A runtime resource splits into a **constructor** and a **provider**: |
| 22 | |
| 23 | ```typescript |
| 24 | // 1. The constructor — what users call. Built on `Platform`. |
| 25 | export const Task: Platform<Task, TaskServices, TaskShape, TaskRuntimeContext> = |
| 26 | Platform("AWS.ECS.Task", { |
| 27 | createRuntimeContext: createHostRuntimeContext("AWS.ECS.Task"), |
| 28 | }); |
| 29 | |
| 30 | // 2. The provider — the lifecycle Layer. Ordinary `Provider.effect`. |
| 31 | export const TaskProvider = () => |
| 32 | Provider.effect(Task, Effect.gen(function* () { /* ... */ })); |
| 33 | ``` |
| 34 | |
| 35 | `Platform` handles the Effect plumbing — running the user's constructor |
| 36 | Effect, collecting handlers, wiring [Bindings](/infrastructure-as-effects/binding) |
| 37 | — so the provider only has to do what every provider does: |
| 38 | reconcile cloud state. |
| 39 | |
| 40 | ## Declare the Resource |
| 41 | |
| 42 | The Resource contract is a plain `Resource` declaration. The fourth |
| 43 | type parameter is the **Binding Contract** — the data shape upstream |
| 44 | capabilities attach to this host: |
| 45 | |
| 46 | ```typescript |
| 47 | export interface Task extends Resource< |
| 48 | "AWS.ECS.Task", |
| 49 | TaskProps, // main, cpu, memory, port, env, ... |
| 50 | { |
| 51 | taskDefinitionArn: string; |
| 52 | imageUri: string; |
| 53 | taskRoleArn: string; |
| 54 | // ... |
| 55 | code: { hash: string }; |
| 56 | }, |
| 57 | { |
| 58 | env?: Record<string, any>; |
| 59 | policyStatements?: PolicyStatement[]; |
| 60 | } |
| 61 | > {} |
| 62 | ``` |
| 63 | |
| 64 | On AWS the contract is env vars + IAM statements; a Cloudflare |
| 65 | Worker instead accepts `{ bindings: Worker.Binding[] }`. Your |
| 66 | contract is whatever *your* platform needs from a binding. |
| 67 | |
| 68 | ## The `Platform` type |
| 69 | |
| 70 | `Platform` takes four type parameters: |
| 71 | |
| 72 | ```typescript |
| 73 | export interface Platform< |
| 74 | Resource extends ResourceLike<string, PlatformProps>, |
| 75 | Services, |
| 76 | MainShape, |
| 77 | RuntimeContext extends BaseRuntimeContext, |
| 78 | > extends Effect.Effect<Resource & RuntimeContext, never, Resource> { /* ... */ } |
| 79 | ``` |
| 80 | |
| 81 | | Parameter | ECS Task instantiation | Meaning | |
| 82 | | ---------------- | ------------------------------------------------------- | -------------------------------------------------------------- | |
| 83 | | `Resource` | `Task` | The Resource contract above | |
| 84 | | `Services` | `Credentials \| Region \| ServerHost \| AWSEnvironment` | Services the runtime provides to the user's constructor Effect | |
| 85 | | `MainShape` | `Main<TaskServices>` | What the constructor Effect may return — `{ fetch?: HttpEffect }` | |
| 86 | | `RuntimeContext` | `TaskRuntimeContext extends HostRuntimeContext` | The mutable context that collects handlers, env, and exports | |
| 87 | |
| 88 | The value-level factory takes the type string and a |
| 89 | `createRuntimeContext` hook (plus an optional `onCreate` hook — |
| 90 | `Cloudflare.Worker` uses it to register child resources for async |
| 91 | bindings): |
| 92 | |
| 93 | ```typescript |
| 94 | import { Platform, type Main } from "alchemy"; |
| 95 | |
| 96 | export type TaskShape = Main<TaskServices>; |
| 97 | |
| 98 | export const Task: Platform<Task, TaskServices, TaskShape, TaskRuntimeContext> = |
| 99 | Platform("AWS.ECS.Task", { |
| 100 | createRuntimeContext: createHostRuntimeContext("AWS.ECS.Task") as ( |
| 101 | id: string, |
| 102 | ) => TaskRuntimeContext, |
| 103 | }); |
| 104 | ``` |
| 105 | |
| 106 | ## The RuntimeContext |
| 107 | |
| 108 | `createRuntimeContext(id)` returns the object that accumulates |
| 109 | everything the constructor Effect registers. Its contract is |
| 110 | `BaseRuntimeContext` from `alchemy/RuntimeContext`: |
| 111 | |
| 112 | ```typescript |
| 113 | export interface BaseRuntimeContext { |
| 114 | Type: string; |
| 115 | id: string; |
| 116 | env: Record<string, any>; |
| 117 | get<T>(key: string): Effect.Effect<T | undefined>; |
| 118 | set(id: string, output: Output): Effect.Effect<string>; |
| 119 | exports?: Effect.Effect<Record<string, any>>; |
| 120 | serve?<Req = never>( |
| 121 | handler: HttpEffect<Req>, |
| 122 | options?: { shape?: Record<string, unknown> }, |
| 123 | ): Effect.Effect<void, never, Req>; |
| 124 | } |
| 125 | ``` |
| 126 | |
| 127 | - **`set`** — called during plan when a binding or config lookup |
| 128 | captures an `Output`; store it under an env key. |
| 129 | - **`get`** — called at runtime inside the deployed artifact; read |
| 130 | the same key back from the environment. |
| 131 | - **`serve`** — receives the user's `fetch` handler (and RPC shape); |
| 132 | register it however your platform serves HTTP. |
| 133 | - **`exports`** — resolves to whatever the bundled entrypoint needs |
| 134 | to run — for hosts, a single `program` Effect. |
| 135 | |
| 136 | Server-style platforms don't write this from scratch — |
| 137 | `createHostRuntimeContext` in |
| 138 | [`Server/Process.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Server/Process.ts) |
| 139 | is the shared implementation used by `AWS.ECS.Task` and |
| 140 | `AWS.EC2.Instance`. It folds `run` (background loops) and `serve` |
| 141 | (HTTP handlers) into one list of runners: |
| 142 | |
| 143 | ```typescript |
| 144 | run: (effect) => Effect.sync(() => { |
| 145 | runners.push(effect); |
| 146 | }), |
| 147 | serve: (handler) => Effect.sync(() => { |
| 148 | runners.push(Http.serve(handler)); |
| 149 | }), |
| 150 | exports: Effect.sync(() => ({ |
| 151 | program: Effect.all(runners, { concurrency: "unbounded" }), |
| 152 | })), |
| 153 | ``` |
| 154 | |
| 155 | After running the constructor Effect, `Platform` hands any returned `fetch` |
| 156 | handler to `serve`, then folds the context back onto the resource's |
| 157 | Props — so everything the constructor recorded reaches your provider: |
| 158 | |
| 159 | ```typescript |
| 160 | // inside Platform (Platform.ts) — what your provider receives as `news` |
| 161 | instance.Props = { |
| 162 | ...props, |
| 163 | env: { ...props?.env, ...runtimeContext.env }, |
| 164 | exports: runtimeContext.exports ? yield* runtimeContext.exports : undefined, |
| 165 | }; |
| 166 | ``` |
| 167 | |
| 168 | ## Implement the Provider |
| 169 | |
| 170 | The provider is a standard lifecycle Layer — `reconcile` + `delete`, |
| 171 | optional `diff`/`read`/`list`. The |
| 172 | [Custom Provider](/infrastructure-as-code/custom-provider) guide covers the |
| 173 | contract in depth; the runtime-specific part is that `reconcile` |
| 174 | also **bundles and ships the handler**: |
| 175 | |
| 176 | ```typescript |
| 177 | reconcile: Effect.fn(function* ({ id, news, bindings, output, session }) { |
| 178 | // 1. Observe/ensure the supporting infrastructure (idempotent helpers). |
| 179 | const taskRoleArn = output?.taskRoleArn |
| 180 | ?? (yield* createTaskRoleIfNotExists({ id, roleName: taskRoleName })); |
| 181 | |
| 182 | // 2. Apply the Binding Contract data (IAM policy + env). |
| 183 | const bindingEnv = yield* attachBindings({ |
| 184 | roleName: taskRoleName, |
| 185 | policyName: taskPolicyName, |
| 186 | bindings, |
| 187 | }); |
| 188 | |
| 189 | // 3. Bundle the runtime Effect and ship it. |
| 190 | const { files, hash } = yield* bundleProgram(id, news); |
| 191 | const imageUri = yield* buildAndPushImage({ id, repositoryUri, hash, files, props }); |
| 192 | const taskDefinition = yield* registerTaskDefinition({ /* ... */ }); |
| 193 | |
| 194 | return { taskDefinitionArn: taskDefinition.taskDefinitionArn!, /* ... */ }; |
| 195 | }), |
| 196 | ``` |
| 197 | |
| 198 | Each step is independently idempotent — the same observe → ensure → |
| 199 | sync flow works for greenfield creates, updates, and adoption. Don't |
| 200 | branch on `output === undefined`; see |
| 201 | [Providers › reconcile](/infrastructure-as-code/provider#reconcile). |
| 202 | |
| 203 | `delete` tears down everything reconcile created (task definition, |
| 204 | ECR repository, log group, IAM roles), treating "already gone" as |
| 205 | success. |
| 206 | |
| 207 | ## Bundle the handler |
| 208 | |
| 209 | `alchemy/Bundle` wraps rolldown. `Bundle.build` takes input/output |
| 210 | options and returns every emitted file plus a content hash: |
| 211 | |
| 212 | ```typescript |
| 213 | import * as Bundle from "alchemy/Bundle"; |
| 214 | |
| 215 | const bundleOutput = yield* Bundle.build( |
| 216 | { input: entry, cwd, platform: "node", external: ["bun", "bun:*"] }, |
| 217 | { format: "esm", entryFileNames: "index.mjs" }, |
| 218 | ); |
| 219 | // bundleOutput.files — entry + chunks; bundleOutput.hash — sha256 |
| 220 | ``` |
| 221 | |
| 222 | Ship all of `files`, not just the entry — dynamic imports split into |
| 223 | chunks, and dropping one crashes the artifact at boot. Use `hash` as |
| 224 | the immutable artifact version (the Task tags its Docker image |
| 225 | `${repositoryUri}:${hash}`). |
| 226 | |
| 227 | The user's `main` module exports the platform class, not a runnable |
| 228 | program — so the bundle needs a generated entrypoint. |
| 229 | `Bundle.virtualEntryPlugin` wraps `main` in a bootstrap that resolves |
| 230 | the class, pulls `RuntimeContext.exports`, and runs the collected |
| 231 | program under your platform's Layers: |
| 232 | |
| 233 | ```typescript |
| 234 | const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin; |
| 235 | |
| 236 | yield* Bundle.build({ |
| 237 | input: realMain, |
| 238 | plugins: [virtualEntryPlugin((importPath) => ` |
| 239 | import { ${handler} as handler } from ${JSON.stringify(importPath)}; |
| 240 | |
| 241 | const program = handler.pipe( |
| 242 | Effect.flatMap((task) => task.RuntimeContext.exports), |
| 243 | Effect.flatMap((exports) => exports.program), |
| 244 | Effect.provide(/* HTTP server + platform Layers */), |
| 245 | Effect.scoped, |
| 246 | ); |
| 247 | |
| 248 | Effect.runPromise(program); |
| 249 | `)], |
| 250 | }, { format: "esm", entryFileNames: "index.mjs" }); |
| 251 | ``` |
| 252 | |
| 253 | The bootstrap is the one place the Effect world meets the process |
| 254 | entrypoint. For the Task it provides a Bun HTTP server bound to |
| 255 | `PORT` so the `{ fetch }` handler is actually served and `host.run` |
| 256 | loops stay alive; your platform provides whatever its host needs. |
| 257 | |
| 258 | ## Honor the phase split |
| 259 | |
| 260 | The same constructor Effect runs twice — at plantime to record bindings, |
| 261 | and at cold start inside the artifact to build live clients. See |
| 262 | [Phases](/infrastructure-as-effects/phases). Two mechanisms keep the |
| 263 | two runs honest, and `Bundle.build` wires the first automatically: |
| 264 | |
| 265 | ```typescript |
| 266 | // Bundle.ts — folded into every bundle via rolldown transform.define |
| 267 | const ALCHEMY_DEFINE = { |
| 268 | "globalThis.__ALCHEMY_RUNTIME__": "true", |
| 269 | }; |
| 270 | ``` |
| 271 | |
| 272 | Every `if (!globalThis.__ALCHEMY_RUNTIME__)` guard in a |
| 273 | `Binding.Service` becomes `if (!true)` in the bundle and is |
| 274 | dead-code-eliminated — provisioning code never ships. |
| 275 | |
| 276 | The second is the `ALCHEMY_PHASE` config key. Your provider must set |
| 277 | it to `"runtime"` in the shipped environment: |
| 278 | |
| 279 | ```typescript |
| 280 | const alchemyEnv = { |
| 281 | ALCHEMY_STACK_NAME: stack.name, |
| 282 | ALCHEMY_STAGE: stack.stage, |
| 283 | ALCHEMY_PHASE: "runtime", |
| 284 | }; |
| 285 | ``` |
| 286 | |
| 287 | `Platform` installs a `ConfigProvider` interceptor keyed on this |
| 288 | phase: at `plan` it captures every config lookup into the |
| 289 | RuntimeContext via `ctx.set`; at `runtime` the same lookup resolves |
| 290 | through `ctx.get` — reading back the env var your provider shipped. |
| 291 | That round-trip is why `set`/`get` must agree on how values are |
| 292 | encoded (the host context JSON-serializes on `set` and |
| 293 | `JSON.parse`s on `get`). |
| 294 | |
| 295 | ## Bindings flow through the host |
| 296 | |
| 297 | When the user writes `yield* AWS.S3.GetObject(bucket)` in the constructor |
| 298 | Effect, the capability calls ``host.bind`${bucket}`(data)`` with |
| 299 | data matching your Binding Contract. The engine collects those and |
| 300 | hands them to `reconcile` as the `bindings` argument: |
| 301 | |
| 302 | ```typescript |
| 303 | const attachBindings = Effect.fn(function* ({ roleName, policyName, bindings }) { |
| 304 | const activeBindings = bindings.filter((binding) => binding.action !== "delete"); |
| 305 | |
| 306 | const env = activeBindings |
| 307 | .map((binding) => binding?.data?.env) |
| 308 | .reduce((acc, value) => ({ ...acc, ...value }), {}); |
| 309 | |
| 310 | const policyStatements = activeBindings.flatMap( |
| 311 | (binding) => binding?.data?.policyStatements ?? [], |
| 312 | ); |
| 313 | |
| 314 | if (policyStatements.length > 0) { |
| 315 | yield* iam.putRolePolicy({ |
| 316 | RoleName: roleName, |
| 317 | PolicyName: policyName, |
| 318 | PolicyDocument: JSON.stringify({ |
| 319 | Version: "2012-10-17", |
| 320 | Statement: policyStatements, |
| 321 | }), |
| 322 | }); |
| 323 | } |
| 324 | |
| 325 | return env; // merged into the shipped environment |
| 326 | }); |
| 327 | ``` |
| 328 | |
| 329 | Your provider decides what the contract data *means* — here IAM |
| 330 | policies attach to the task role and env vars land in the container |
| 331 | definition. A binding removed from code arrives with |
| 332 | `action: "delete"` so you can converge permissions down, not just up. |
| 333 | |
| 334 | ## What users get |
| 335 | |
| 336 | The finished runtime reads like any other Function — infrastructure |
| 337 | props, a constructor Effect, a `{ fetch }` handler: |
| 338 | |
| 339 | ```typescript |
| 340 | import * as AWS from "alchemy/AWS"; |
| 341 | import { ServerHost } from "alchemy/Server"; |
| 342 | import * as Effect from "effect/Effect"; |
| 343 | |
| 344 | export default class ApiTask extends AWS.ECS.Task<ApiTask>()( |
| 345 | "ApiTask", |
| 346 | { main: import.meta.filename, cpu: 256, memory: 512, port: 3000 }, |
| 347 | Effect.gen(function* () { |
| 348 | const host = yield* ServerHost; |
| 349 | yield* host.run(pollLoop); // long-running background work |
| 350 | |
| 351 | return { |
| 352 | fetch: Effect.gen(function* () { |
| 353 | return HttpServerResponse.text("hello from ecs task"); |
| 354 | }), |
| 355 | }; |
| 356 | }), |
| 357 | ) {} |
| 358 | ``` |
| 359 | |
| 360 | Everything on this page sits behind that surface: `Platform` runs |
| 361 | the constructor Effect and collects `host.run` + `fetch` into |
| 362 | `exports.program`, and the provider bundles that program into an |
| 363 | image and reconciles the task definition around it. |
| 364 | |
| 365 | ## Where next |
| 366 | |
| 367 | - [Runtime](/infrastructure-as-effects/runtime) — |
| 368 | the model this guide implements a new target for. |
| 369 | - [Custom Provider](/infrastructure-as-code/custom-provider) — the |
| 370 | lifecycle contract (`reconcile`, `delete`, `diff`, `read`) in depth. |
| 371 | - [Phases](/infrastructure-as-effects/phases) — the Construction/Runtime split |
| 372 | your bundle must honor. |