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.
Layers build once per isolate
Section titled “Layers build once per isolate”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 runsconst pool = yield* acquirePool();
// ✅ handler: acquired per event, released when the event settlesfetch: 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.
Deploy from GitHub Actions
Section titled “Deploy from GitHub Actions”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@v1A 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.
PlanetScale OAuth login
Section titled “PlanetScale OAuth login”alchemy login now offers browser-based OAuth for PlanetScale,
the same flow as Cloudflare
(#467):
alchemy login# authorize in the browser → token stored, refreshed proactivelyEnv vars and service tokens keep working. Both providers now land
on shared /auth/success and /auth/error pages
(#780).
Custom Worker entry for Vite websites
Section titled “Custom Worker entry for Vite websites”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 handlerimport 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.
alchemy dev reliability
Section titled “alchemy dev reliability”- 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 foundafter 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).
Also in this release
Section titled “Also in this release”planno 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 --configureswitched accounts, state reads/writes silently kept going to the old account’s store. - Provider handlers receive the resource
fqn(#783) — the fully-qualified name joinsidin 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.cronhandler 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
Websitedeploys. - Container healthcheck durations are unit-suffixed (#778) — Docker rejects bare numbers. Thanks Matthew Aylward!
alchemy state clearrespectsALCHEMY_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).