用 @alchemy.run/pr-package 在 Cloudflare 上自建内容寻址的 PR 包分发服务
用 alchemy.run/pr-package 在 Cloudflare 上自建内容寻址的 PR 包分发服务【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3codealchemy.run/pr-package是 AlchemyInfrastructure-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、完整 SHAmain这类长期 tag 始终指向最新构建。由于 tarball 字节不变相同内容只需上传一次——这正是内容寻址的核心收益。从 README 可知该包将四种 Cloudflare 资源封装进单个 Effecthandler资源职责R2 bucket按(package, sha256)存储.tgzblobKV namespace保存tag → 内容寻址 tarball 指针映射Secrets Store Random生成的 bearer token为写入类操作鉴权Durable Object记录每个 tarball 的下载统计并调度 TTL 到期清理对应源码中的四个资源声明分别是 Bucket.tsCloudflare.R2.Bucket(PrPackageBucket)、TagIndex.tsCloudflare.KV.Namespace(PrPackageHashTagIndex)、AuthToken.ts 与 PackageStore.ts。二、安装与架构边界bun add alchemy.run/pr-package从 package.json 可见其依赖仅alchemyworkspace 内部依赖与effectcatalog 版本并通过exports同时提供types/bun/worker/import四套入口sideEffects: false便于 tree-shaking。为什么包不直接拥有 WorkerCloudflare 从单个入口文件打包 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 hosts 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.WorkerApi()( 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.asstring(), // 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.run、xn--cu8h.alchemy.run即 .alchemy.run与 Distilled 宿主pkg.distilled.cloud等并支持带 scope 的三段路径scope/pkg/tag→{ pkgName: scope/pkg, tag }同时用decodeURIComponent容错解析编码段。3.4handler(options)选项OptionTypeDefaultNotesparseAliasUrl(url: URL) AliasMatch \| null() null将任意非/projects/...的 GET 映射为{ pkgName, tag }以返回 301defaultTtlstringEffect Duration3 weeks当 tag 请求未携带Alchemy-TTL时应用的 TTLAliasMatch为{ 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为作用域包名可带 scopescope/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.tsContent-Length必须是正整数否则 400若已存在但 size 不匹配返回 400上传时把十六进制 SHA-256 转成字节数组作为 R2put的sha256参数由 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 weeksEffectDuration语法行为要点若 tag 已指向其他 tarball会先迁移读取旧 KV 指针从旧 tarball 的 Durable Object 状态移除该 tag若旧 tarball 因此成为“孤儿”最后一个 tag 被移除则同步删除 R2 blobAlchemy-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-age31536000, 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 ) basehttps://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流程解读bun pm pack生成 tarballsha256sum计算内容寻址哈希wc -c取字节数供Content-Length先HEAD探测——已存在则跳过上传幂等、省带宽不存在才PUT上传用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保持本地状态与云端一致。八、核心机制小结从源码看整个服务的正确性建立在三个关键设计上内容寻址 幂等上传R2 对象键即packages/sha256相同字节永不重复存储HEAD/PUT幂等tag 即轻量指针KV 只存tag → TarballRef的 JSON 指针tag 迁移与删除通过 Durable Object 中的引用计数orphaned联动 R2 blob 生命周期过期清理闭环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),仅供参考