用 @alchemy.run/pr-package 在 Cloudflare 上自建内容寻址的 PR 包分发服务
2026/9/13 11:53:06 网站建设 项目流程

用 @alchemy.run/pr-package 在 Cloudflare 上自建内容寻址的 PR 包分发服务

【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code

@alchemy.run/pr-package是 Alchemy(Infrastructure-as-Effects,将云基础设施与应用逻辑统一为类型安全的 Effect 程序)仓库中一个可自托管的 PR 包服务包:它以 R2 + KV + Secrets Store + Durable Object 四种 Cloudflare 资源拼装成一个 Effecthandler,把 npm tarball 按(package, sha256)内容寻址存储、用临时 tag 指向它们,从而让开发者可以通过https://pkg.example.com/my-pkg/abc1234这样的漂亮 URL 直接bun add安装 PR 预览包。读完本文你将掌握:该服务的资源架构与路由设计、如何在自有的Cloudflare.Worker栈中接入它、如何从 CI 发布预览包并在 PR 上生成可粘贴的安装指令,以及 tag 过期清理与状态回收机制。

本文基于 pr-package 包源码 与仓库内真实部署示例(栈文件、Worker 入口、CI 工作流)撰写。

一、设计动机:为什么需要“PR 包”服务

常规 npm 包在合并到main后才发布,PR 评审者无法直接安装分支产物。PR 包服务把每次 PR 的构建结果发布为可安装的临时包:tag 指向具体 commit(短 SHA、完整 SHA),main这类长期 tag 始终指向最新构建。由于 tarball 字节不变,相同内容只需上传一次——这正是内容寻址的核心收益。

从 README 可知,该包将四种 Cloudflare 资源封装进单个 Effecthandler

资源职责
R2 bucket(package, sha256)存储.tgzblob
KV namespace保存tag → 内容寻址 tarball 指针映射
Secrets Store +Random生成的 bearer token为写入类操作鉴权
Durable Object记录每个 tarball 的下载统计,并调度 TTL 到期清理

对应源码中的四个资源声明分别是 Bucket.ts(Cloudflare.R2.Bucket("PrPackageBucket"))、TagIndex.ts(Cloudflare.KV.Namespace("PrPackageHashTagIndex"))、AuthToken.ts 与 PackageStore.ts。

二、安装与架构边界

bun add @alchemy.run/pr-package

从 package.json 可见其依赖仅alchemy(workspace 内部依赖)与effect(catalog 版本),并通过exports同时提供types/bun/worker/import四套入口,sideEffects: false便于 tree-shaking。

为什么包不直接拥有 Worker?Cloudflare 从单个入口文件打包 Worker,而parseAliasUrl是一个 JS 闭包——它必须位于(或可从)你栈文件的模块图触达。因此 Worker 类必须放在你自己的工程里,包只贡献路由逻辑。这是 README 中明确说明的设计边界。

三、最小可用部署(两文件模式)

3.1 Worker 入口文件

// stacks/pr-package/Api.ts — the worker entry (main: import.meta.url) import * as PrPackage from "@alchemy.run/pr-package"; import * as Cloudflare from "alchemy/Cloudflare"; const parseAliasUrl: PrPackage.ParseAliasUrl = (url) => { // Map any alias host's URL to { pkgName, tag }, or return null to fall through. // E.g. https://pkg.example.com/<pkg>/<tag>: const segments = url.pathname.split("/").filter(Boolean); if (segments.length === 2) { return { pkgName: segments[0]!, tag: segments[1]! }; } return null; }; export default class Api extends Cloudflare.Worker<Api>()( "PrPackageWorker", { main: import.meta.url, url: true, domain: ["pkg.example.com"], compatibility: { flags: ["nodejs_compat"], date: "2026-03-17" }, }, PrPackage.handler({ parseAliasUrl }), ) {}

3.2 栈文件

// stacks/pr-package.ts — the stack import * as PrPackage from "@alchemy.run/pr-package"; import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Output from "alchemy/Output"; import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import Api from "./pr-package/Api.ts"; export default Alchemy.Stack( "PrPackage", { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { const authToken = yield* PrPackage.AuthTokenValue; const api = yield* Api; return { url: api.url.as<string>(), // Unwrap the Redacted so the stack output emits the real token — // otherwise it serializes to the literal string "<redacted>". authToken: authToken.text.pipe(Output.map(Redacted.value)), }; }), );

部署:

bun alchemy deploy ./stacks/pr-package.ts --stage prod

栈输出给出 Worker URL 与自动生成的 bearer token,请妥善保存该 token——发布时需要它。

为什么必须拆成两个文件?若把Worker类与Alchemy.Stack(...)放进同一文件,会把 alchemy CLI/状态存储的代码面拉进 Worker bundle,运行时报No such module "sisteransi"之类的错误。将 Worker 类独立成文件可保持 Worker bundle 最小化。仓库内真实示例即按此模式组织:Worker 类在 stacks/pr-package/Api.ts,栈在 stacks/pr-package.ts。

3.3 真实示例中的 parseAliasUrl

仓库自身的 Api.ts 展示了更完整的别名解析:它区分主域名pkg.ing/staging.pkg.ing、Alchemy 宿主(pkg.alchemy.runxn--cu8h.alchemy.run,即 📦.alchemy.run)与 Distilled 宿主(pkg.distilled.cloud等),并支持带 scope 的三段路径(@scope/pkg/tag{ pkgName: "@scope/pkg", tag }),同时用decodeURIComponent容错解析编码段。

3.4handler(options)选项

OptionTypeDefaultNotes
parseAliasUrl(url: URL) => AliasMatch \| null() => null将任意非/projects/...的 GET 映射为{ pkgName, tag }以返回 301
defaultTtlstring(Effect Duration)"3 weeks"当 tag 请求未携带Alchemy-TTL时应用的 TTL

AliasMatch{ pkgName: string; tag: string };返回null则回落到常规/projects/:pkgName/...匹配器。在 Worker.ts 中可以看到默认值实现:options.parseAliasUrl ?? (() => null)options.defaultTtl ?? "3 weeks"

四、资源层与键设计

4.1 内容寻址的键模型

Tarball.ts 定义了核心数据结构:

  • TarballRef = readonly [packageName: string, hash: string]——tarball 的唯一身份;
  • tarballId(ref)JSON.stringify(ref),作为 KV 值(即 KV 中tag:<pkg>:<tag>→ 该 JSON 串)与 Durable Object 名称;
  • tarballKey(ref)${encodeURIComponent(packageName)}/${hash}.tgz,作为 R2 对象键——即 URL 的packages/:sha256段对应 R2 对象名,天然内容寻址。

4.2 四个 Cloudflare 资源的绑定

handler通过 Worker.ts 中的bindings = Layer.mergeAll(R2.ReadWriteBucketBinding, KV.ReadWriteNamespaceBinding, SecretsStore.ReadSecretBinding)在服务内部完成依赖注入,而 PackageStore.ts 则以DurableObject形式自绑 R2 与 KV 绑定用于过期清理。

4.3 鉴权实现

requireAuth逻辑在 Worker.ts 中:读取Authorization头,与 Secrets Store 中PrPackageAuthToken秘钥(由 AuthToken.ts 中Random("PrPackageAuthTokenValue")生成)比较Bearer ${Redacted.value(expected)},不匹配则Effect.fail(new Unauthorized()),由外层Effect.catchTag("Unauthorized", ...)统一转成 401 JSON 响应。Redacted保证 token 不会出现在日志或序列化输出中。

五、HTTP API 全览

所有路由均以:pkgName为作用域,包名可带 scope(@scope/name)或不带(name),与 npm 命名一致。

HEAD /projects/:pkgName/packages/:sha256— 探测

检查由(package, sha256)标识的后备 tarball 是否已存在。需鉴权;存在返回 200,否则 404。CI 可先探测再决定是否上传,避免重复传输字节。

PUT /projects/:pkgName/packages/:sha256— 上传

当内容寻址的后备 tarball 不存在时上传原始.tgz流。要求鉴权、Content-Type: application/gzip与匹配的Content-Length。重复请求是幂等的,不会覆盖已有内容。

源码细节(Worker.ts):Content-Length必须是正整数,否则 400;若已存在但 size 不匹配返回 400;上传时把十六进制 SHA-256 转成字节数组作为 R2putsha256参数,由 R2 侧校验内容完整性。

PUT /projects/:pkgName/tags— 指向 tag

为已有 tarball 分配 tag,无需重复上传字节。请求头:

  • Authorization: Bearer <token>(必填)
  • Alchemy-Tarball-Hash: <sha256>(必填)
  • Alchemy-Tags: <json-array>(必填)——如["main","abc1234","abc1234abc1234..."]
  • Alchemy-TTL: <duration>(可选)——如"7 hours""3 weeks",EffectDuration语法

行为要点:

  • 若 tag 已指向其他 tarball,会先迁移:读取旧 KV 指针,从旧 tarball 的 Durable Object 状态移除该 tag,若旧 tarball 因此成为“孤儿”(最后一个 tag 被移除)则同步删除 R2 blob;
  • Alchemy-Tags必须是非空字符串 JSON 数组,去重后写入;
  • TTL 解析使用Duration.fromInput,非法格式或非正时长返回 400;
  • 分配 tag 会调度一个命名的 Durable Object 到期事件(EXPIRATION_EVENT = "expire")。到期触发时,服务移除所有仍指向该 tarball 的 KV tag、删除 R2 blob、清除 tarball 状态。到期前重新分配 tag 会重新调度事件;
  • 若请求的 hash 对应 tarball 不存在,返回 404。

GET /<alias-path>— 漂亮安装 URL → 301

只要路径不以/projects/开头,请求 URL 就被交给parseAliasUrl(url)。若返回匹配,Worker 301 到/projects/:pkgName/tags/:tag;否则 404。源码中aliasRedirectPath(match)会用encodeURIComponent逐段编码 pkgName 与 tag,保证 scope 与特殊字符安全。

GET /projects/:pkgName/tags/:tag— 解析 tag → 302 到 tarball

查找 tag 的(package, sha256)指针、记录一次下载,然后 302 重定向到不可变的 tarball URL。源码会额外校验指针中的 package 与请求一致,否则 500。

GET /projects/:pkgName/packages/:sha256— 提供 tarball

返回.tgz,响应头cache-control: public, max-age=31536000, immutable。无需鉴权——URL 本身已内容寻址,天然不可变、可长期缓存(一年)。

DELETE /projects/:pkgName/tags/:tag— 移除 tag

需鉴权。若该 tag 是 tarball 的最后一个,底层 blob 也会被删除(orphaned逻辑,与 tag 迁移共用同一套引用计数)。

GET /projects/:pkgName/packages/:sha256/stats— 下载统计

需鉴权。返回{ downloads: { [tag]: number }, totalDownloads: number }。实现上通过parseTarballPath的正则^\/packages\/([a-f0-9]{64})(\/stats)?$区分普通 tarball 请求与 stats 请求,统计存于 Durable Object 的PackageStatedownloads按 tag 计数、totalDownloads汇总)。

六、从 CI 发布

README 给出可直接复制的发布脚本:

bun pm pack --destination . tgz=$(ls *.tgz) hash=$(sha256sum "$tgz" | cut -d ' ' -f 1) size=$(wc -c < "$tgz" | tr -d ' ') base="https://pkg.example.com/projects/my-pkg" curl -fsSI -H "Authorization: Bearer ${PR_PACKAGE_TOKEN}" \ "$base/packages/$hash" || \ curl -fsS -X PUT -H "Authorization: Bearer ${PR_PACKAGE_TOKEN}" \ -H "Content-Type: application/gzip" -H "Content-Length: $size" \ --data-binary "@$tgz" "$base/packages/$hash" curl -fsS -X PUT -H "Authorization: Bearer ${PR_PACKAGE_TOKEN}" \ -H "Alchemy-Tarball-Hash: $hash" \ -H "Alchemy-Tags: [\"${GITHUB_SHA:0:7}\",\"$GITHUB_SHA\",\"main\"]" \ "$base/tags"

流程解读:

  1. bun pm pack生成 tarball,sha256sum计算内容寻址哈希,wc -c取字节数(供Content-Length);
  2. HEAD探测——已存在则跳过上传(幂等、省带宽);不存在才PUT上传;
  3. PUT /tags同时打上短 SHA、完整 SHA 与main三个 tag——短 SHA 供 PR 评论展示,完整 SHA 保证可精确回溯,main始终指向最新构建。

消费者安装:

bun add https://pkg.example.com/projects/my-pkg/tags/abc1234 # or via parseAliasUrl, e.g.: bun add https://pkg.example.com/my-pkg/abc1234

仓库内的完整 CI 参考

本仓库的 .github/workflows/pr-package.yml 是该模式的生产级落地:监听mainpush 与 PR 的opened/synchronize/reopened/labeled事件(故意不监听closed——tag 在 PR 关闭后依然存续,保证已有安装 URL 持续可用);PR 事件设并发组并以新提交取消旧预览构建,而 main 发布绝不中断;构建产物通过alchemy-run/actions/actions/pr-package发布(packages数组中声明@alchemy.run/pr-package等多个包),再由pr-package-commentaction 在 PR 上贴出带各包安装 URL 的粘性评论。

七、清理孤儿状态

若部署中途出错留下孤儿状态:

bun alchemy state resources <StackName> <stage> ./your/stack.ts --profile <p> bun alchemy state clear <StackName> <stage> ./your/stack.ts --profile <p> --yes

随后在重新部署前,通过 Cloudflare 控制台核对实际已创建的云资源(R2 bucket、KV namespace、Secrets Store、Durable Object),保持本地状态与云端一致。

八、核心机制小结

从源码看,整个服务的正确性建立在三个关键设计上:

  1. 内容寻址 + 幂等上传:R2 对象键即packages/<sha256>,相同字节永不重复存储,HEAD/PUT幂等;
  2. tag 即轻量指针:KV 只存tag → TarballRef的 JSON 指针,tag 迁移与删除通过 Durable Object 中的引用计数(orphaned)联动 R2 blob 生命周期;
  3. 过期清理闭环:Durable Object 调度expire事件(失败后按RETRY_DELAY_MS = 60_000重试),到期统一清理 KV tag、R2 blob 与自身状态,init重新调度实现 TTL 刷新。

这套设计让 PR 包服务以极小的自定义业务代码(核心路由集中在 Worker.ts 约 400 行,状态管理在 PackageStore.ts)跑在 Cloudflare 免费额度内,适合自托管 npm 预览分发的场景。

【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询