Skip to content

2.0.0-beta.62 - Build-Once Workers & GitHub Action

beta.62 makes Workers build their layer stack once per isolate instead of on every request. It also adds an official GitHub Action, PlanetScale OAuth login, a custom Worker entry for Vite websites, and a batch of alchemy dev fixes.

The Worker bridge used to rebuild your entire layer stack on every request. Every Layer.effect, config decode, and client constructor re-ran per event. Now layers build once, on the first event. Every later event reuses them (#774):

Effect.gen(function* () {
// init: runs once per isolate. beta.61 ran this on every request.
const client = yield* HttpClient.HttpClient;
const response = yield* client.get("https://example.com/config.json");
const config = yield* response.json;
return {
fetch: Effect.gen(function* () {
// handler: runs per event, reusing the cached config
return yield* HttpServerResponse.json(config);
}),
};
})

Init cost is paid once at cold start. After that, every request goes straight to the handler. Durable Objects get the same win: one layer build shared across activations.

Sharing one build across events needs cross-request async. One event awaits a layer build that another event started. workerd normally forbids this: a promise created in one request context can’t be resolved from another. So alchemy enables the handle_cross_request_promise_resolution compatibility flag automatically for Workers with a compatibility date before 2024-10-14. Later dates enable it by default. Each awaiting event also pins the in-flight build with ctx.waitUntil, so workerd doesn’t drop the continuation when the original request ends. Opting out with no_handle_cross_request_promise_resolution is a deploy-time error.

There is one migration. Anything disposable moves out of init and into the handler. The handler’s per-event Scope runs its finalizers after the response:

// ❌ init: workerd has no isolate teardown, so this finalizer never runs
const pool = yield* acquirePool();
// ✅ handler: acquired per event, released when the event settles
fetch: Effect.gen(function* () {
const conn = yield* acquireConn();
// ...
}),

You can still declare the resource in init and use it at runtime. An Effect is only a description, so declaring one in init acquires nothing:

import * as PgClient from "@effect/sql-pg/PgClient";
Effect.gen(function* () {
// init: declare the client. Nothing connects yet.
const client = PgClient.make({ url });
return {
fetch: Effect.gen(function* () {
// handler: connects on this event's Scope
const sql = yield* client;
const rows = yield* sql`select * from users`;
return yield* HttpServerResponse.json(rows);
}),
};
})

The pool is acquired on the event’s Scope and released when the event settles.

proxyChain removes the extra yield*. It wraps the deferred client in a chainable handle. Yielding a query resolves the client on the current event’s Scope, then replays the chain against it:

import { proxyChain } from "alchemy/Util/proxy-chain";
Effect.gen(function* () {
// init: a chainable handle over the deferred client
const sql = proxyChain(PgClient.make({ url }));
return {
fetch: Effect.gen(function* () {
const rows = yield* sql`select * from users`;
return yield* HttpServerResponse.json(rows);
}),
};
})

Drizzle.postgres is this pattern plus Effect.cached to memoize the acquisition per event. The first query opens the pool, every later query in the same event reuses it, and the pool closes when the event settles:

Effect.gen(function* () {
// init: declares the handle. No connection is made.
const db = yield* Drizzle.postgres(hyperdrive.connectionString);
return {
fetch: Effect.gen(function* () {
// first query builds the pool, memoized on this event's Scope
const rows = yield* db.select().from(users);
return yield* HttpServerResponse.json(rows);
}),
};
})

See Postgres.ts for the full implementation. It is also why Drizzle.postgres now works inside Durable Object methods.

Lambda gets a real shutdown from the same change. The generated entry registers an internal extension, so the instance receives SIGTERM and a ~500 ms window to run init-level finalizers.

Docs: Instance scope vs request scope.

One step resolves the Alchemy lifecycle from the GitHub event. Opening a PR deploys a staging-{pr} stage, closing it destroys the stage, and pushing to main deploys prod (#699). Thanks Harry Solovay!

- uses: alchemy-run/alchemy-effect@v1

A new GitHub.GitHubEnv config exposes the Actions metadata to your stack. It resolves to undefined outside CI, so the same stack runs unchanged locally:

import * as GitHub from "alchemy/GitHub";
const github = yield* GitHub.GitHubEnv;
if (github?.pr) {
yield* GitHub.Comment("preview-comment", {
owner: github.owner,
repository: github.repository,
issueNumber: github.pr,
body: `Preview for ${github.sha}`,
});
}

Docs: CI.

alchemy login now offers browser-based OAuth for PlanetScale, the same flow as Cloudflare (#467):

Terminal window
alchemy login
# authorize in the browser → token stored, refreshed proactively

Env vars and service tokens keep working. Both providers now land on shared /auth/success and /auth/error pages (#780).

Cloudflare.Website.Vite accepts a main prop that forwards a custom Worker entry to the Vite plugin, in dev and deploy (#779). Thanks Daniel Gangl!

// worker/index.ts: export more than the framework's fetch handler
import handler from "virtual:react-router/unstable_rsc/server";
export { MyDurableObject } from "./my-durable-object";
export default handler;
yield* Cloudflare.Website.Vite("Site", {
rootDir: "./site",
main: "./site/worker/index.ts",
});

React Router / RSC setups need this. The framework plugin owns the entry, so the old rollupOptions.input workaround doesn’t apply.

  • Hot reload no longer kills service bindings (#812) — reloading a worker used to delete its replacement’s registration, so RPC calls failed with Worker not found after every backend change. Thanks utopy!
  • Container environment variables work in dev (#785).
  • Dev container images persist across restarts (#727).
  • Remote images and external Dockerfiles work in LocalContainerProvider (#790).
  • The provider RPC session is cached and reused (#789).
  • Dev-URL detection favors localhost/IP URLs (#787).
  • plan no longer claims ownership of adopted resources (#794) — a read-only dry run could silently adopt an unowned cloud resource, and a later unrelated deploy would orphan-delete it. Thanks Daniel Gangl!
  • State-store credentials are bound to the account (#760) — after alchemy login --configure switched accounts, state reads/writes silently kept going to the old account’s store.
  • Provider handlers receive the resource fqn (#783) — the fully-qualified name joins id in every lifecycle handler, so custom providers can stamp collision-free ownership metadata. Thanks Daniel Gangl!
  • Cron handlers keep their requirements (#791) — services required by a Workers.cron handler surface on the Worker’s init effect again. Thanks Jack Bisceglia!
  • Vite build output is read after the build resolves (#795) — fixes a bundling race in Website deploys.
  • Container healthcheck durations are unit-suffixed (#778) — Docker rejects bare numbers. Thanks Matthew Aylward!
  • alchemy state clear respects ALCHEMY_PROFILE (ba705).
  • TypeScript 7 (stable) toolchain (#804).
  • New guides: Worker Loader, Rate limiting, and Workers Cache (#805), plus a TanStack + Atom RPC + Drizzle example (#634).