Skip to content

2.0.0-beta.78 - Git Hosting, Stripe & Worker Previews

beta.78 ships alchemy/Git, an embeddable, pluggable git server built from Effect Layers. Mount it inside any Effect HTTP API and pick where refs and bytes live — Durable Objects and R2 on Cloudflare, S3 and Lambda on AWS, or a store of your own. A new Stripe provider provisions Stripe resources and binds them to your Workers and Functions. Worker Previews give every branch its own URL and isolated Durable Objects. The GitHub provider gains ten resources, alchemy dev runs on half the memory, and a round of fixes to RDS, S3, and security groups repairs drift and restores defaults when props are removed.

alchemy now requires effect >=4.0.0-rc.115 (#1562, #1601):

Terminal window
bun add alchemy@latest effect@rc @effect/platform-bun@rc @effect/platform-node@rc

rc.113 renamed the Config and Flag constructors to PascalCase:

Config.string("HOST")
Config.String("HOST")
Config.mapOrFail(...)
Config.mapEffect(...)
Flag.boolean("yes")
Flag.Boolean("yes")
Flag.choice("backend", …)
Flag.Literals("backend", …)

rc.113 also replaced @effect/sql-pg’s pg dependency with a native wire client, which sent no SNI and rejected sslmode=prefer. Both broke Aurora DSQL, Neon, and local Hyperdrive; alchemy’s SQL.Postgres, Drizzle.Postgres, and postgresState now derive the TLS config from the URL (#1580, #1582), and rc.115 fixes the SNI half upstream (Effect-TS/effect#8174).

alchemy/Git is a pluggable, embeddable git server (#1187, #1621). It speaks smart HTTP to any git client, serves a typed REST plane with pull requests, and answers the GitHub REST v3 API so gh api and Octokit work against it unchanged. Nothing ships a Worker — every part is an Effect service with swappable Layers, assembled into a Worker you own:

import * as Git from "alchemy/Git";
import * as Http from "alchemy/Http";
export const GitObjects = Cloudflare.R2.Bucket("GitObjects");
const GitLive = Git.ApiLive.pipe(
Layer.provide(Git.ApiHandlersLive), // the engine's route handlers
Layer.provide(Authentication.layer), // yours: who may call what
Layer.provide(Git.ReposDurableObject), // refs, objects, pull requests
Layer.provide(Git.RegistryDurableObject), // owner/name → repo
Layer.provide(Git.HasherInline), // push verification
Layer.provide(Git.BlobStoreR2(GitObjects)), // packs, bundles, large pushes
);
export default Cloudflare.Worker(
"Git",
{ main: import.meta.url, ...Git.GIT_WORKER_OPTIONS },
Effect.gen(function* () {
const fetch = yield* HttpRouter.toHttpEffect(
GitLive.pipe(Layer.provide(Http.Platform)),
);
return { fetch };
}),
);

Each line is one decision with its own implementations. Change a line and only that decision changes — here the bytes move to S3:

const GitObjects = Cloudflare.R2.Bucket("GitObjects");
const GitObjects = AWS.S3.Bucket("GitObjects");
const GitLive = Git.ApiLive.pipe(
Layer.provide(Git.ApiHandlersLive),
Layer.provide(Authentication.layer),
Layer.provide(Git.ReposDurableObject),
Layer.provide(Git.RegistryDurableObject),
Layer.provide(Git.HasherInline),
Layer.provide(Git.BlobStoreR2(GitObjects)),
Layer.provide(Git.BlobStoreS3(GitObjects)),
);

You build the HTTP server and mount alchemy/Git’s routes into it. Git.Api is an Effect HttpApi group and Git.Handlers supplies the default implementation of every route as a plain function. Add the group to your own API and put your own HttpApiMiddleware in front of it, and every request — git clone and git push, the REST API, and the GitHub-compatible API gh uses — goes through your authentication and your user management. The engine itself holds no users, no credentials, and no policy.

The middleware is native HttpApiMiddleware. Declare what it provides, add the Git groups to your API, and put it in front:

class Authentication extends HttpApiMiddleware.Service<
Authentication,
{ provides: CurrentUser }
>()("app/Authentication", { error: Unauthorized }) {}
class AppApi extends HttpApi.make("app")
.addHttpApi(Git.Api) // repos, refs, objects, pulls, protocol, github
.add(AppRoutes) // your own routes beside them
.middleware(Authentication) {}

Its implementation is an ordinary Layer — resolve a session cookie, an API key, or the password git sends over HTTP Basic, and provide CurrentUser to the endpoint. examples/cloudflare-git-service does this with Better Auth.

Because the handlers are yours to call, a route can be overridden with a few lines of application code. Here receivePack reuses the defaults for the read routes and wraps the push with a branch rule that reads the CurrentUser the middleware provided:

const ProtocolLive = HttpApiBuilder.group(AppApi, "protocol", (h) =>
Effect.gen(function* () {
const git = yield* Git.Engine;
const defaults = yield* Git.Handlers;
return h
.handleRaw("infoRefs", defaults.protocol.infoRefs)
.handleRaw("uploadPack", defaults.protocol.uploadPack)
.handleRaw("receivePack", ({ params, request }) =>
Effect.gen(function* () {
const repo = yield* git.repositories.get(params);
const push = yield* GitHttp.ReceivePack.decode(request);
const user = yield* CurrentUser; // provided by Authentication
if (push.updates.some((u) => u.ref === "refs/heads/main" && user.id !== repo.owner)) {
return GitHttp.ReceivePack.reject(push, "only the owner moves main");
}
const prepared = yield* git.preparePush(repo, push.input);
return GitHttp.ReceivePack.response(push, yield* git.commitPush(prepared));
}),
);
}),
);

preparePush verifies the pack without moving refs; commitPush moves them atomically. The same host runs behind a shared secret, Better Auth, or anything else — the tutorial walks from one to the other, ending with the full version of this handler in Protect a branch.

We benchmarked it against GitHub with real git from the same client and edge: a 36 MiB, 15.6k-object repository, a fresh repository on each host.

alchemy/Git GitHub
Full git clone 0.7 s best, 1.3 s typical 1.3 s best, 2.1 s typical
Clone throughput 80 MiB/s best, 33 MiB/s typical 28 MiB/s best, 17 MiB/s typical
Incremental fetch 0.4–0.6 s 0.3–0.5 s
Incremental push 0.2–0.4 s 1.2 s
44 MiB push, whole blobs 4.8–6.4 s 4.6 s
40 MiB push, delta-heavy 5.9–6.5 s 3.3 s

GitHub’s throughput is the pack size divided by its clone time; the incremental fetch is a git fetch with one new commit to pick up. Clones are fast because the work is done ahead of time. After every push the repository writes a ready-to-serve bundle to R2, and a git clone is the Worker streaming that file to the client. The Durable Object is not involved, so the number of concurrent clones a repository can serve is limited by R2 and Workers, not by one object: four clones at once reached 93 MiB/s aggregate. Everyday pushes, which move a few commits, are faster than GitHub; incremental fetches are about even. Large pushes are the one place GitHub is ahead. The largest real push so far is the alchemy monorepo, 44k objects in a 67 MiB pack, hosted on git.alchemy.run and cloned back byte-identical under git fsck --strict.

Before a ref moves, every object in a push is inflated, SHA-1 hashed, and delta-resolved. For a kilobyte push that is nothing; for a 40 MiB push it is a lot of CPU, and Workers are a poor place for it. A Worker’s CPU is slow for exactly this kind of work — tight loops over bytes, inflate, and hashing run several times slower than on a laptop — and there is one thread to run it on: a service binding call executes on the caller’s thread, so fanning out to another Worker buys nothing. The first version hashed on the request thread and took 10–13 seconds for the 40 MiB push:

Layer.provide(Git.HasherInline)

Git.Hasher is the Layer where a chunk gets hashed, so the fix was a second implementation, not a change to the engine. HasherWorkerLoader bundles the hasher as a module inside your Worker and loads it into four fresh isolates per push, each with its own 128 MB and its own thread. Nothing new is deployed, nothing leaves Cloudflare, and the same push drops to 5.9–6.5 seconds:

import * as GitHasher from "alchemy/Git/Hasher";
const GitLive = Git.ApiLive.pipe(
Layer.provide(Git.ApiHandlersLive),
Layer.provide(Authentication.layer),
Layer.provide(Git.ReposDurableObject),
Layer.provide(Git.RegistryDurableObject),
Layer.provide(Git.HasherInline),
Layer.provide(GitHasher.HasherWorkerLoader()),
Layer.provide(Git.BlobStoreR2(GitObjects)),
);

HasherLambda is the third implementation — one Lambda invocation per chunk, all at once, on a 3 GB function — for pushes wider than four chunks when an AWS account is already in the stack. If a hasher fails, the Worker hashes the chunk inline; a push never fails because its hasher did. Incremental pushes take 0.2–0.4 s on every one of them, so start with HasherInline and swap when large pushes start to matter.

Scaling and Hasher have the full numbers; the design notes in DESIGN.md record every step of the sweep.

It is a git remote you own, with an API, that you can build on — not a GitHub replacement. No issues, reviews, or Actions.

Docs: Git · Getting Started · Tutorial · Building blocks · Scaling.

A new Stripe provider for managing Stripe with alchemy (#1298) — thanks Michael K! It comes with resources (Product, Price, Coupon, PromotionCode, PaymentLink, BillingPortalConfiguration, WebhookEndpoint, and Connect’s Account), bindings for calling Stripe from a Worker (CreateCustomer, CreateCheckoutSession, Retrieve*, …) that wire the API key into the Worker for you — each one registers the permission it needs, so there is no key to copy into env — and consumeEvents, an event source that provisions the webhook endpoint from the Worker’s URL and hands your handler a typed event:

export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const product = yield* Stripe.Product("Pro", { name: "Pro" });
const price = yield* Stripe.Price("ProMonthly", {
product,
currency: "usd",
unitAmount: 2000,
recurring: { interval: "month" },
});
const priceId = yield* price.id;
const createCustomer = yield* Stripe.CreateCustomer();
const createCheckout = yield* Stripe.CreateCheckoutSession();
yield* Stripe.consumeEvents(
"Events",
{ events: [Stripe.CheckoutSessionCompleted, Stripe.InvoicePaymentFailed] },
Effect.fn(function* (event) {
yield* Effect.log(event.type);
}),
);
return {
fetch: Effect.gen(function* () {
const customer = yield* createCustomer({ email: "a@example.com" });
const session = yield* createCheckout({
mode: "subscription",
customer: customer.id,
line_items: [{ price: yield* priceId, quantity: 1 }],
success_url: "https://example.com/welcome",
cancel_url: "https://example.com/pricing",
});
return HttpServerResponse.redirect(session.url!);
}).pipe(Effect.orDie),
};
}).pipe(
Effect.provide([
Stripe.CreateCustomerHttp,
Stripe.CreateCheckoutSessionHttp,
Stripe.ConsumeEventsLive,
]),
),
) {}

consumeEvents verifies signatures (a bad one is a 401) and creates the WebhookEndpoint for you; declare one directly only when you own the delivery URL.

Two runnable examples: examples/stripe-billing (Checkout, Billing Portal, and an entitlement record in KV kept current by webhooks) and examples/stripe-connect (Express accounts, hosted onboarding, and account.updated driving a D1 merchant table).

Docs: Stripe · Sell a subscription · Onboard merchants with Connect · React to Stripe events.

preview is the sibling of version (#1563): a named copy of another Worker with its own URL, its own bindings, and isolated same-Worker Durable Objects. Production traffic is untouched.

const parent = yield* Cloudflare.Worker.ref("Api", { stage: "prod" });
const api = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
preview: { of: parent },
});
// api.url → https://<stage>-<worker>.<account>.workers.dev

The Preview name defaults to the stack stage (pr-123). Destroying the Preview Worker deletes the Preview; the parent stays up. Enable domain: { name: "app.example.com", previews: true } on the parent and Previews serve at https://<preview-name>.app.example.com.

preview.of // branch / PR — own URL, isolated DOs
version.parent // canary of another stage
version.traffic // gradual rollout of this Worker
version.traffic: 0 // upload without routing

Docs: Worker Previews.

GitHub: rulesets, protection, releases, and more

Section titled “GitHub: rulesets, protection, releases, and more”

Ten new resources on the GitHub provider. BranchProtection (#1514) covers the classic protection API — thanks 99andytang! — and Agusti F. contributed the other nine: Ruleset, Label, Milestone, Issue, PullRequest, Release, Collaborator, TeamAccess, and WikiPage (#1565#1578). Thanks both!

yield* GitHub.Ruleset("main-protection", {
owner: "my-org",
repository: "my-repo",
name: "main protection",
target: "branch",
conditions: { include: ["refs/heads/main", "refs/heads/release/*"] },
rules: {
nonFastForward: true,
deletion: true,
requiredLinearHistory: true,
pullRequest: {
requiredApprovingReviewCount: 2,
requireCodeOwnerReview: true,
dismissStaleReviewsOnPush: true,
},
},
});
yield* GitHub.TeamAccess("platform", {
owner: "my-org",
repository: "my-repo",
teamSlug: "platform",
permission: "push",
});

Docs: GitHub.

The alchemy dev process tree drops from 3.6 GB to 1.9 GB on the Vite example under Node, and a cold import of alchemy/Cloudflare from 523 MB to 214 MB.

  • One sidecar, not one per provider group (#1613). A group is imported the first time a stack uses one of its types, so a Cloudflare-only stack never loads the AWS emulator.
  • Less engine in every process (#1614). ProviderLayer.dual stops building its default variant at registration, the CLI bins skip the alchemy/Cli barrel, and the Cloudflare provider no longer imports all 122 distilled services for one helper. Sidecar heap: 193 MB → 98 MB.
  • Transforms are cached on disk (#1615). The Oxc loader writes output to a per-user cache keyed by path, size, mtime, and tsconfig instead of re-transpiling the source graph (with inline source maps) in every Node process. ALCHEMY_TRANSFORM_CACHE=0 disables it.
  • The workerd proxy is a node:net byte pipe (#1612) instead of a second workerd per Worker. HTTP/1.1, streaming, and WebSockets pass through untouched; the Worker sees the real Host, so request.url is the public URL (#1573); a request during a broken build gets a 502 with the build error.

Two beta.77 crashes are fixed too: alchemy dev under Bun could not import any stack because the dev probe intercepted the root’s own node_modules (#1617, thanks Austin!), and the interactive CLI died with ReactSharedInternals.H.useMemo when a consumer’s install hoisted a different React — Sigil now bundles its own (#1636).

A run of provider fixes from Bjorn Pagen and Henning Pokriefke — thanks both! — closes gaps where RDS, S3, and EC2 security groups trusted state instead of the cloud: drift went unrepaired on unchanged deploys, and removing a prop left the old value in place instead of restoring the documented default.

Security groups apply rule deltas instead of revoke-and-recreate (#1592, #1664), and an inline-rule update no longer deletes SecurityGroupRule resources attached to the same group (#1624). egress has three distinct states (#1623):

// Omitted: AWS's default — allow all outbound IPv4.
AWS.EC2.SecurityGroup("DefaultEgress", { vpcId });
// Explicitly empty: no outbound traffic.
AWS.EC2.SecurityGroup("NoEgress", { vpcId, egress: [] });
// Non-empty: only what is listed.
AWS.EC2.SecurityGroup("HttpsEgress", {
vpcId,
egress: [{ ipProtocol: "tcp", fromPort: 443, toPort: 443, cidrIpv4: "0.0.0.0/0" }],
});

RDS DBInstance reconciles storage as one configuration — allocation, type, IOPS, throughput, and the autoscaling limit sent together when AWS requires it (#1593, #1594) — compares the observed endpoint port (#1595), and restores the engine-default parameter group and the VPC default security group when their associations are removed (#1596). DBParameterGroup converges to declared overrides and reads back settled values (#1589, #1590).

yield* AWS.RDS.DBInstance("Db", {
engine: "postgres",
dbInstanceClass: "db.t3.micro",
masterUsername: "admin",
manageMasterUserPassword: true,
allocatedStorage: 20, // a minimum — RDS cannot shrink storage
maxAllocatedStorage: 100, // remove it to disable autoscaling
});

S3 buckets and AWS.state() gain encryption.blockedEncryptionTypes: ["SSE-C"] (#1588), compare decoded KMS key values so matching keys skip PutBucketEncryption (#1587), propagate configuration read failures instead of reconciling over them (#1586), and check ownership with GetBucketLocation so configuration-only operators no longer need s3:ListBucket (#1585).

Docs: RDS · S3.

  • Preview packages at pkg.alchemy.run (#1516) — every PR, branch, and commit publishes installable packages from a Cloudflare Worker registry that verifies each publication against the GitHub Actions run that built it, so fork PRs publish with no secrets. Dependencies are content-addressed, so shared packages deduplicate across PRs.

    Terminal window
    pnpm install https://pkg.alchemy.run/alchemy/pr:1516
    pnpm install https://pkg.alchemy.run/alchemy/branch:main
  • Typed named entrypoint bindings (#1415) — Cloudflare.WorkerEntrypoint<Api>(target, "Api") types env.API as Service<Api>, so env.API.greet("alice") typechecks. Thanks Michael K!

  • Railway sandboxes: domains, sizing, forks, checkpoints (#1661) — publicDomains, resources: { cpu, memoryGB }, and Railway.SandboxCheckpoint to snapshot a prepared box and restore it as a template. Railway reconcilers now select only the GraphQL fields they need (#1604), and MountVolume actually attaches the disk (#1584).

  • Prisma Compute serves static apps (#1208) — build: "auto" recognizes Vite SPA output and adds the Bun entrypoint Prisma requires; build: { type: "static", spa: true } serves a prebuilt directory. Thanks Aman Varshney! The Prisma provider also moves onto the distilled SDK (#1290, #1669) — thanks Will Madden!

  • Cloudflare Containers — a batch from Dan van der Merwe, thanks! memoryMib is forwarded (#1521), Dockerfile builds export straight to the registry with buildx build --push (#1524), identical images publish once per deploy (#1525), a cached identity that no longer matches Cloudflare plans a visible replacement instead of erroring (#1523), interrupted creates recover by generated name (#1526), and an unresolved Worker namespace no longer detaches the container’s DO (#1150). The dev egress interceptor pulls host-native, fixing containers on Apple Silicon (#1459) — thanks Erik Müller!

  • R2 put forwards options on the stream path (#1602) — a declared sha256/md5 on an Effect Stream body is now verified by R2 instead of silently dropped.

  • Crons fire in Website.Vite dev workers (#1641). Thanks zawaki!

  • alchemy/Cloudflare/Bridge (#1643) — the Worker bridge generated wrappers import is a runtime-only entry, so deploy tooling stays out of the bundle. Thanks Alex!

  • EC2 instances with a fixed private IP replace delete-first (#1667) — the successor can’t take the address while the predecessor holds it. Thanks dawson!

  • AWS Organizations rewrite missing ownership tags (#1637) instead of reporting them restored. Thanks Henning Pokriefke!

  • Log group ARNs from state are normalized before tagging (#1575) — a :* suffix persisted by an old adoption no longer fails every converge with Invalid resourceArn. Thanks Evan Spaeder!

  • ECR images skip updates when only the context directory moves (#1591).

  • Destroy converges after drifted stateSNS.Subscription and DynamoDB.Table tolerate an already-deleted parent (#1576), and ECS.Service deletes past a draining service (#1580). Hetzner servers and their deploy keys recover after an interrupted create (#1662).

  • HTTP state-store errors carry no payloads (#1522) — a failed write reports a failure kind and status, never the request or response body. Thanks Dan van der Merwe!

  • CLI fixes — the published alchemy dev resolves its sidecar through package exports (#1678), colors follow the terminal’s detected profile instead of forcing truecolor (#1679), and the exec bundle keeps its .js extension (#1625).

  • Smaller dependency closurejszipfflate (#1608), fast-globtinyglobby (#1609), and Foldkit ^0.160.0 for effect rc.115 (#1618).

  • Core docs read as one narrative (#1652) — each concept has one owning page; /infrastructure-as-effects redirects to What is Alchemy.