十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

t3code 生态中的 Alchemy 2.0.0-beta.37:跨栈引用、类型化绑定与 R2 自动清空的 IaC 实践指南

t3code 生态中的 Alchemy 2.0.0-beta.37:跨栈引用、类型化绑定与 R2 自动清空的 IaC 实践指南 t3code 生态中的 Alchemy 2.0.0-beta.37跨栈引用、类型化绑定与 R2 自动清空的 IaC 实践指南【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code本篇文章基于 Alchemy 项目官方发布说明2026-05-12-beta-37.md展开全面解析v2.0.0-beta.37中最核心的七大能力跨栈/跨阶段资源引用、Alchemy.Secret/Alchemy.Variable一行式声明、完全类型化的 Worker-to-Worker 绑定、带类型化输入输出的 Workflows、Cron 触发器、Analytics Engine 绑定以及 R2 桶销毁时自动清空。读完本文你将能够利用这些能力在 PR 预览阶段复用staging数据库、在 monorepo 中跨栈读取部署输出并写出端到端类型安全的多 Worker 云应用。说明本仓库通过reference-repos机制同步了 Alchemy 的源码与示例见 scripts/lib/reference-repos.ts文中所引源码均位于.repos/alchemy-effect/下供读者对照验证。一、版本背景beta.37 解决了什么v2.0.0-beta.37被官方称为一段时间以来最大的 beta。其头号特性是cross-stack跨栈与 cross-stage跨阶段引用——这是让 PR 预览阶段共享staging数据库、而不必在每次有人打开草稿 PR 时重新供应一整套 Postgres 集群的缺失拼图。在此基础上该版本还带来了完全类型化的 Worker-to-Worker 绑定无需 codegen、无需手写接口、无需as强转带类型化输入/输出的 Workflow I/O一行式Alchemy.Secret/Alchemy.VariableCron 触发器支持Analytics Engine 绑定R2 桶销毁时自动清空emptyOnDestroy。其中多项功能来自核心团队之外的贡献者Dawson、Michael K、Baptiste Arnaud、Zé Yuri、齐天大圣等完整致谢见文末 Contributors 一节。对应源码位于 .repos/alchemy-effect/packages/alchemy/src/Cloudflare/R2、Workers、Workflows、AnalyticsEngine、KV 等模块与 .repos/alchemy-effect/packages/alchemy/src/Neon/Project、Branch 等模块。二、跨栈与跨阶段引用Cross-stack and cross-stage references2.1 核心概念跨栈/跨阶段引用是指惰性lazy、类型化地引用由另一个 stack 或 stage 部署的资源。它的典型设计用例是临时性的 PR 预览阶段需要一个 Neon 项目但不应自掏腰包创建一个新的而应共享一个由staging阶段拥有的项目。从实现上看两种引用形态最终都落到 Output.ts 与 Resource.ts 中对state store状态存储的读取——Output.stackRef与Resource.ref是底层原语Neon.Project.ref、yield* Backend等新 API 只是让它们更符合人体工程学。2.2 把 PR 阶段指向staging的数据库在同一个alchemy.run.ts中根据 stage 条件分支若 stage 形如 PR 预览pr-147、pr-148……则用Neon.Project.ref深入staging阶段的 state file取回已部署的 Neon 项目否则该阶段创建自己的项目。// src/Db.ts import * as Alchemy from alchemy; import * as Drizzle from alchemy/Drizzle; import * as Neon from alchemy/Neon; import * as Effect from effect/Effect; export const NeonDb Effect.gen(function* () { const { stage } yield* Alchemy.Stack; const schema yield* Drizzle.Schema(app-schema, { schema: ./src/schema.ts, out: ./migrations, }); // PR previews share the long-lived staging project. // Every other stage gets its own. const project stage.startsWith(pr-) ? yield* Neon.Project.ref(app-db, { stage: staging }) : yield* Neon.Project(app-db, { region: aws-us-east-1 }); // Branches are cheap and per-stage either way. const branch yield* Neon.Branch(app-branch, { project, migrationsDir: schema.out, }); return { project, branch, schema }; });上述代码是发布说明中的简化版。仓库中的完整示例位于 .repos/alchemy-effect/examples/cloudflare-neon-drizzle/src/Db.ts注意真实示例将引用阶段写为stage: \staging-${stage}并把Neon.Branch的迁移源直接接到 schema 资源上migrations: schema注释中还点明了 provider 的执行顺序Drizzle.Schema重新生成待执行的迁移 SQL 文件Neon.Branch扫描该目录并以事务方式应用新迁移。两者结合可以看作发布说明示例的生产版。这段代码有三个要点相同的逻辑 id、相同的类型app-db与staging创建项目时使用的 id 一致无论走哪条分支project都是Neon.Project类型因此下游Neon.Branch({ project })根本不知道也不关心它是真实创建还是引用而来。在 plan 阶段解析Alchemy 从staging持久化的 state store对应仓库源码目录中读取项目的属性id、host 等。若staging尚未部署plan 会以InvalidReferenceError大声失败——这是一个刻意的快速失败设计避免把引用了一个不存在的资源悄悄带到云端。PR 销毁范围被约束alchemy destroy --stage pr-147只删除该 PR 的Neon.Branch不会触碰共享项目——因为这个 stage 并不拥有它。部署时只需先部署一次staging之后所有 PR 阶段都可以指向它alchemy deploy --stage staging # creates the project once alchemy deploy --stage pr-147 # references it, creates only the branch2.3 引用整个栈的输出Monorepo 场景上面是跨阶段引用单个资源的形态另一种形态是拉取整个栈的输出——在 monorepo 中当 frontend 包想读取 backend 栈部署出来的 URL 时就用这种形态。首先声明一次类型化的栈句柄// backend/src/Stack.ts import * as Alchemy from alchemy; export class Backend extends Alchemy.Stack Backend, { url: string } ()(Backend) {}用Backend.make(...)部署 backend即Alchemy.Stack的类型化简写然后在 frontend 的栈里yield* Backend即可拿回它的输出全程类型检查// frontend/alchemy.run.ts import * as Alchemy from alchemy; import * as Cloudflare from alchemy/Cloudflare; import { Backend } from backend; import * as Effect from effect/Effect; export default Alchemy.Stack( Frontend, { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { // Resolves Backends outputs from the same stage of the same // stack name. pr-42 frontend reads pr-42 backend. const backend yield* Backend; // ^? { url: string } return yield* Cloudflare.Website.Vite(Website, { env: { VITE_API_URL: backend.url }, }); }), );yield* Backend默认解析为与消费方相同的 stage。当你需要固定指向某个具体阶段时——例如生产 frontend 无论由哪个分支部署都始终读取生产 backend——使用Backend.stage.nameconst backend yield* Backend.stage.prod; // always pin to prod const backend yield* Backend.stage[pr-42]; // arbitrary stage nameyield* Backend的默认行为同 stage 同栈名让pr-42的 frontend 自然读到pr-42的 backend这正是 PR 预览环境的理想语义。三、Alchemy.Secret与Alchemy.Variable一行式环境变量接线把环境变量接进部署目标是栈里最无聊的部分过去要散落在三个文件里。现在一次yield就把它压缩成一行而且这行同时还是一个类型化的运行时访问器。// alchemy.run.ts — declare once on the Worker export default Cloudflare.Worker(Api, { main: import.meta.filename }, Effect.gen(function* () { const apiKey yield* Alchemy.Secret(OPENAI_API_KEY); // ^? OutputRedactedstring return { fetch: Effect.gen(function* () { // …and read the bound value inside the handler. const key yield* apiKey; // Redactedstring return HttpServerResponse.text( key has ${Redacted.value(key).length} chars, ); }), }; }), );Alchemy.Variable与Secret形状相同只是没有Redacted包裹const port yield* Alchemy.Variable(PORT, 3000); const flags yield* Alchemy.Variable(FLAGS, { beta: true }); // inside fetch const p yield* port; // number — 3000 const f yield* flags; // { beta: true }两者的参数形态都很灵活可以接受一个字面量、一个Effect、一个Config或者默认从活跃ConfigProvider中按同名读取值。同一个调用会路由到平台原生的 secret/variable 绑定——Cloudflare 上是secret_textAWS 上是 Lambda 加密环境变量——而运行时访问器会把值解码回原始类型。也就是说声明与读取共用同一个句柄声明处决定了平台的绑定形态读取处决定了类型。四、Worker-to-Worker 绑定全类型化的三种调用形态现在一个 Worker 可以绑定到另一个 Worker 上作为 binding且调用方在另一端获得完整的 RPC 类型——不需要 codegen、不需要手写接口、不需要对env做as强转。三种调用形态都在同一次部署上可用。以发布说明中的示例为例一个BackendWorker 同时暴露一个 RPC 方法与一个 HTTP 路由另有一个 TanStack Start 前端用三种不同方式调用它对应仓库中的 .repos/alchemy-effect/examples/cloudflare-website-tanstack-start/ 示例项目文中代码做了裁剪。后端 Worker// src/backend.ts import * as Cloudflare from alchemy/Cloudflare; import * as Effect from effect/Effect; import { HttpServerRequest } from effect/unstable/http/HttpServerRequest; import * as HttpServerResponse from effect/unstable/http/HttpServerResponse; export const Bucket Cloudflare.R2.Bucket(Bucket); export default class Backend extends Cloudflare.WorkerBackend()( Backend, { main: import.meta.filename }, Effect.gen(function* () { const bucket yield* Cloudflare.R2.ReadWriteBucket(Bucket); return { // RPC method — callable via backend.hello(key) on the other side. hello: Effect.fn(Backend.hello)(function* (key: string) { const object yield* bucket.get(key); return object null ? null : yield* object.text(); }), // HTTP handler — callable via env.BACKEND.fetch(...). fetch: Effect.gen(function* () { const request yield* HttpServerRequest; const key new URL(request.url, http://backend).searchParams.get(key); if (!key) return HttpServerResponse.text(missing key, { status: 400 }); if (request.method GET) { const object yield* bucket.get(key); return object null ? HttpServerResponse.text(not found, { status: 404 }) : HttpServerResponse.stream(object.body); } return HttpServerResponse.text(method not allowed, { status: 405 }); }), }; }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)), ) {}把它作为 binding 接到另一个 Worker 上// alchemy.run.ts import Backend, { Bucket } from ./src/backend.ts; export const Website Cloudflare.Website.Vite(Website, { bindings: { BUCKET: Bucket, // R2 binding BACKEND: Backend, // Worker-to-Worker binding }, });现在调用方有三种与后端对话的方式三者都真实可用、都带类型、都能在同一个 handler 里混用——按调用点需求挑选即可// frontend route handler import * as Cloudflare from alchemy/Cloudflare; import type Backend from ../backend.ts; import { env } from ../env.ts; // Option 1 — async binding (just call the platform API directly). const object await env.BUCKET.get(key); // Option 2 — Worker-to-Worker fetch over the service binding. const res await env.BACKEND.fetch(https://backend/?key${encodeURIComponent(key)}); // Option 3 — typed RPC. toPromiseApiBackend wraps the wire-shape // binding into a PromiseT view that throws on Effect.fail and // unwraps stream envelopes — full method signatures from Backend. const backend Cloudflare.toPromiseApiBackend(env.BACKEND); const value await backend.hello(key); // ^? string | null (typed end-to-end)Effect 原生的调用方还有第四条路——yield* Backend.bind(env.BACKEND)返回同样的 RPC 表面但没有 Promise 信封。各形态的取舍很清晰Option 1异步绑定直接调用平台 API适合对象存储这类原生接口Option 2service binding fetch需要请求/响应语义、特别是流式 body时用这个Option 3类型化 RPC想要类型化方法调用时用这个toPromiseApiBackend会把线上形态包装成PromiseT视图Effect.fail会抛异常流信封会被解包。五、Workflows类型化输入与输出Cloudflare.Workflow现在对输入和输出类型都是泛型。函数体是一个直接接收类型化输入的Effect.fnworkflow.create(input)端到端类型检查返回值一路流到instance.status().output。发布说明给了一个非常贴近实战的例子——一个通知型 workflow触碰 KV、读取Alchemy.Secret、通过 Durable Object 广播、sleep、最后收尾。每个副作用都用task包裹这样崩溃 重放时会返回持久化结果而不是重新执行// src/NotifyWorkflow.ts import * as Alchemy from alchemy; import * as Cloudflare from alchemy/Cloudflare; import * as Effect from effect/Effect; import * as Redacted from effect/Redacted; import { KV } from ./KV.ts; import Room from ./Room.ts; export default class NotifyWorkflow extends Cloudflare.WorkflowNotifyWorkflow()( Notifier, Effect.gen(function* () { // Outer init phase: resolve shared dependencies once. const rooms yield* Room; const kv yield* Cloudflare.KV.ReadWriteNamespace(KV); const secret yield* Alchemy.Secret(WORKFLOW_SECRET); return Effect.fn(function* (input: { roomId: string; message: string }) { const { roomId, message } input; // Each task is a checkpoint — replay-safe. const stored yield* Cloudflare.Workflows.task(kv-roundtrip, Effect.gen(function* () { const key notify:${roomId}; yield* kv.put(key, message); return (yield* kv.get(key)) ?? message; }).pipe(Effect.orDie), ); const value Redacted.value(yield* secret); const processed yield* Cloudflare.Workflows.task(process, Effect.succeed({ text: Processed: ${stored}, secret: value }), ); yield* Cloudflare.Workflows.task(broadcast, rooms.getByName(roomId).broadcast([workflow] ${processed.text}), ); yield* Cloudflare.Workflows.sleep(cooldown, 2 seconds); yield* Cloudflare.Workflows.task(finalize, rooms.getByName(roomId).broadcast([workflow] complete for ${roomId}), ); return processed; }); }), ) {}这个例子值得细读它展示了 workflow 的完整编程模型外层 init 阶段只解析一次共享依赖rooms、KV 命名空间、Alchemy.Secret随后返回一个Effect.fn作为 workflow 主体每个Cloudflare.Workflows.task(名称, ...)都是一个检查点checkpoint——崩溃重放时直接返回持久化结果Redacted.value负责把 secret 解包为可用的字符串值Cloudflare.Workflows.sleep(cooldown, 2 seconds)在 workflow 内部休眠这是被持久化的虚拟时间不是阻塞真实线程。从 Worker 中启动它——create针对输入形状做类型检查instance.status()报告类型化输出// inside a Workers fetch handler const notifier yield* NotifyWorkflow; const instance yield* notifier.create({ roomId: room-42, message: hello, }); // instance.id: string const status yield* (yield* notifier.get(instance.id)).status(); // status.output: { text: string; secret: string } | undefined六、Cron 触发器Cloudflare.Workers.cron(...)订阅一个 Cloudflare Cron Trigger并挂上 Effect handler。部署时的一半负责把 cron 表达式挂到宿主 Worker 上运行时的一半负责注册scheduled监听器。它可以与任何已接好的 binding 并用——handler 运行在同一个 Worker 上下文中。export default Cloudflare.Worker(Reporter, { main: import.meta.filename }, Effect.gen(function* () { const kv yield* Cloudflare.KV.ReadWriteNamespace(Counters); // Fires once at the top of every hour. yield* Cloudflare.Workers.cron(0 * * * *).subscribe((controller) Effect.gen(function* () { yield* kv.put(tick:${controller.scheduledTime}, ok); yield* Effect.log(tick at ${new Date(controller.scheduledTime).toISOString()}); }), ); return { fetch: Effect.succeed(HttpServerResponse.text(ok)) }; }), );几点注意多次调用cron(…)会在同一个 Worker 上注册多个 scheduleCloudflare 提供的最细 cron 粒度是一分钟* * * * *controller.scheduledTime是触发时间戳可直接用于写 KV key 或日志订阅句柄.subscribe(handler)的 handler 是 Effect 程序天然融入现有错误处理链路。七、Analytics Engine 绑定Cloudflare Workers Analytics Engine 现在以**零配置zero-provisioning**的 Worker 绑定形式暴露声明一个 dataset 资源、在 Worker 上绑定、从 handler 里调用writeDataPoint——与 Alchemy 其它绑定一样走同一个 Effect 错误通道。// alchemy.run.ts export const Events Cloudflare.AnalyticsEngine.Dataset(Events, { dataset: app-events, }); // inside the Worker export default Cloudflare.Worker(Api, { main: import.meta.filename }, Effect.gen(function* () { const analytics yield* Cloudflare.AnalyticsEngineDataset.bind(Events); return { fetch: Effect.gen(function* () { yield* analytics.writeDataPoint({ indexes: [account-1], // queryable, low-cardinality blobs: [signup], // arbitrary string columns doubles: [1], // numeric metrics }); return HttpServerResponse.text(recorded); }), }; }).pipe(Effect.provide(Cloudflare.AnalyticsEngineDatasetBindingLive)), );writeDataPoint的三个字段语义与平台保持一致indexes可查询、低基数的索引字段如账号 idblobs任意字符串列如事件名doubles数值型指标。由于是零供应绑定Analytics Engine 不需要单独创建基础设施声明 dataset 资源即完成了接线。对应实现位于 .repos/alchemy-effect/packages/alchemy/src/Cloudflare/AnalyticsEngine/。八、R2 桶销毁时自动清空destroy一个Bucket时现在会先排空桶内内容再删除桶。这彻底消除了 teardown 期间的BucketNotEmpty失败——临时 PR 预览和集成测试现在只需一次alchemy destroy就能干净收场const Photos Cloudflare.R2.Bucket(Photos); // alchemy destroy empties Photos and deletes it in one go如果你出于生产环境的保护意图想要旧行为桶非空则失败可以按桶选择退出const Photos Cloudflare.R2.Bucket(Photos, { emptyOnDestroy: false });这行配置与 R2 模块源码.repos/alchemy-effect/packages/alchemy/src/Cloudflare/R2/中的 Bucket 资源选项对应emptyOnDestroy默认值为true。九、值得关注的修复项D1prepare()/bind()现在同步了与上游 Cloudflare Workers API 对齐——琐碎的语句构造不再需要yield*。本地 sidecar 中的 WASM 模块bun alchemy dev现在能正确地把.wasm模块打进本地 sidecar修复了一类依赖 WASM 的 Worker 的 module not found 错误。未解析的Output做 JS 强制转换时直接抛错在 Effect 之外误把未解析的Outputstring用在模板字符串里例如${bucket.bucketName}过去会被静默强转为[object Output]并带着垃圾值部署上云现在会直接抛错。移除废弃的 libsodium 包装类型无公开 API 影响但如果你 import 过内部类型它们已经不存在了。十、如何在当前仓库中进一步探索如果你想把本文讲到的能力落实到自己的基础设施代码中可以从以下几个入口继续深入示例工程完整的 Neon Drizzle 示例见 .repos/alchemy-effect/examples/cloudflare-neon-drizzle/src/Db.tsTanStack Start 前后端桥接示例见 .repos/alchemy-effect/examples/cloudflare-website-tanstack-start/。源码模块跨栈引用的底层原语在 Output.ts 与 Resource.tsNeon 相关资源在 .repos/alchemy-effect/packages/alchemy/src/Neon/Project.ts、Branch.tsCloudflare 全家桶在 .repos/alchemy-effect/packages/alchemy/src/Cloudflare/其中Workers/、Workflows/、R2/、AnalyticsEngine/、KV/与本版本特性一一对应。变更记录本版本的完整 CHANGELOG 与上一版本beta.36的对比可在 .repos/alchemy-effect/CHANGELOG.md 中找到。一个实用的落地建议如果你正在为 PR 预览环境维护共享数据库先alchemy deploy --stage staging部署一次长生命周期资源再在Db.ts中以stage.startsWith(pr-)为条件切换ref/新建两条路径最后用alchemy destroy --stage pr-147验证销毁范围确实被约束在 per-PR 分支上——这正好是本文第二节所讲能力的最小闭环演练。【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表