feat: 新增 cloud-artifacts 包(Cloudflare Worker HTML artifact 托管)

POST /upload 鉴权上传 HTML 到 R2 返回 hash URL,GET /<7d|30d>/<id>.html
由 Worker 代理读取并直出 text/html。R2 lifecycle rule 自动 7/30 天删除。
独立服务,不被主 CLI 引用(类似 packages/remote-control-server/ 定位)。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-20 13:34:39 +08:00
parent 5d74071ebf
commit 617254b2b5
10 changed files with 791 additions and 1 deletions

View File

@@ -0,0 +1 @@
TOKEN=replace-with-your-bearer-token

171
packages/cloud-artifacts/.gitignore vendored Normal file
View File

@@ -0,0 +1,171 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars*
!.dev.vars.example
.env*
!.env.example
.wrangler/
# wrangler types 生成物(每次 wrangler types / dev / deploy 后会刷新,含 Cloudflare 运行时类型,体积大、会触发 biome lint
worker-configuration.d.ts

View File

@@ -0,0 +1,150 @@
# cloud-artifacts
独立的 Cloudflare Worker + R2 服务,用于托管 HTML artifact。
服务端CLI / RCS 后台)通过单一 bearer token 上传 HTML得到一个公开可访问的 CDN URL。**POST 上传和 GET 访问都走 Worker**URL 形如 `https://<worker-domain>/<ttl-prefix>/<id>.html`,文件到期由 R2 lifecycle rule 自动删除。
## 架构
```
客户端 --POST /upload--> Worker (鉴权 + 校验 + R2 put) --返回 {id, url, expiresAt}
客户端 --GET html-------> Worker (R2 get → text/html) --直出 html带 Cache-Control: max-age=86400
R2 lifecycle rule: 7d/ 删 7 天30d/ 删 30 天
```
- Worker 处理 `POST /upload``GET /<prefix>/<id>.html`
- R2 key 形如 `7d/<id>.html``30d/<id>.html`
- URL 形如 `https://<worker-domain>/7d/<id>.html`hash 本身是秘密21 字符 nanoId126 bit 熵)
## 部署
前置:本机已 `npx wrangler login` 登录目标 Cloudflare 账号,且账号下有一个用于 Worker custom domain 的域名zone
```bash
cd packages/cloud-artifacts
bun install # 在 monorepo 根执行也行workspace 自动识别)
cp .dev.vars.example .dev.vars # 填本地 dev 用的 TOKEN仅 wrangler dev 读)
bun run setup # 创建 bucket + 加 lifecycle rule + 设生产 TOKEN secret
# 绑定 Worker custom domain在 Cloudflare dashboard
# Workers & Pages > cloud-artifacts > Settings > Domains & Routes > Add > Custom Domain
# 填入你的 domain如 artifacts.example.com
# 改 wrangler.toml 中 [vars] PUBLIC_URL 为上一步的 domain如 https://artifacts.example.com
bun run deploy
```
## API
### `POST /upload`
| Header / Query | 必填 | 说明 |
|----------------|------|------|
| `Authorization: Bearer <TOKEN>` | 是 | 与 Worker secret `TOKEN` 完全相等 |
| `Content-Type: text/html` | 是 | 不接受其他类型 |
| `?ttl=7\|30` | 否 | 默认 7**只允许 7 或 30**(与 R2 lifecycle prefix 对应) |
| `?hash=<custom-id>` | 否 | 自定义 ID校验 `^[A-Za-z0-9_-]{1,128}$`;指定时覆盖同 ID 旧版本 |
| body | 是 | 原始 HTML`--data-binary @file.html`≤10MB |
成功 200
```json
{
"id": "V1StGXR8_Z5jdHi6B-myT",
"url": "https://<worker-domain>/7d/V1StGXR8_Z5jdHi6B-myT.html",
"expiresAt": "2026-06-27T10:00:00.000Z"
}
```
错误(统一 `{ "error": "<code>" }`
| 状态码 | error code | 触发条件 |
|--------|------------|----------|
| 400 | `invalid_ttl` | `ttl` 非 7 或 30 |
| 400 | `invalid_hash` | `hash` 不匹配 `^[A-Za-z0-9_-]{1,128}$` |
| 401 | `unauthorized` | 缺 Authorization / token 不匹配 |
| 404 | `not_found` | 非 `/upload` 路径或 GET 路径不匹配 `/<7d\|30d>/<id>.html` |
| 413 | `payload_too_large` | body > 10MB |
| 415 | `unsupported_media_type` | Content-Type 非 `text/html` |
### `GET /<ttl-prefix>/<id>.html`
由 Worker 处理:解析路径 → R2 get → 返回 `text/html; charset=utf-8` + `Cache-Control: public, max-age=86400`。任何人拿到 URL 都可访问hash 即秘密。
`ttl-prefix` 只能是 `7d``30d`(其他路径返回 404
## 示例
```bash
# 上传(默认随机 ID + 7 天)
echo '<h1>hello</h1>' > /tmp/t.html
curl -X POST "https://<worker-domain>/upload" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/html" \
--data-binary @/tmp/t.html
# -> {"id":"V1StGXR8_Z5jdHi6B-myT","url":"https://<worker-domain>/7d/V1StGXR8_Z5jdHi6B-myT.html","expiresAt":"..."}
# 自定义 hash + 30 天(再次上传同 hash 覆盖)
curl -X POST "https://<worker-domain>/upload?ttl=30&hash=my-report" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/html" \
--data-binary @/tmp/report.html
# 访问(公开 URL走 Worker → R2
curl "https://<worker-domain>/7d/V1StGXR8_Z5jdHi6B-myT.html"
```
## 覆盖语义
指定 `?hash=` 时:
1. 校验 hash 字符集(`^[A-Za-z0-9_-]{1,128}$`
2. 删除 `7d/<hash>.html``30d/<hash>.html` 两个 keyR2 delete 不存在的 key 不报错,零成本)
3.`?ttl=` 写入新 key
4. 返回新的 `expiresAt`
不指定 `?hash=` 时:用 `nanoid(21)` 随机 ID几乎不可能碰撞不做碰撞检查。
## TTL 落地
R2 不支持 per-object TTL本服务用 prefix + R2 lifecycle rule 模拟:
- bucket 配两条 ruleprefix `7d/` 删 7 天前对象、prefix `30d/` 删 30 天前对象
-`scripts/setup.sh``wrangler r2 bucket lifecycle add` 自动配置
- Worker 完全不参与过期处理,零额外代码
- 因此 `?ttl=` 只能取 `7``30`,对应这两个 prefix其他值会写到无 lifecycle 的 prefix → 永久存储,故拒绝)
## 本地开发
```bash
cp .dev.vars.example .dev.vars
# 编辑 .dev.vars 填 TOKEN
bun run dev # wrangler dev启动本地 Miniflare + 本地 R2 模拟
curl -X POST "http://localhost:8787/upload" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: text/html" \
--data-binary @/tmp/t.html
```
## 测试
`scripts/test.sh` 覆盖 7 个错误用例 + 3 个成功用例 + R2 写入验证:
```bash
WORKER_URL=https://<worker-domain> \
TOKEN=<your-token> \
bash scripts/test.sh
```
## 依赖
- `wrangler` ^4 — Cloudflare Workers CLI
- `nanoid` ^5 — ID 生成(纯 ESMWorker 兼容)
## 不被主 CLI 引用
这是独立 Cloudflare Worker 服务,类似 `packages/remote-control-server/` 的定位。Monorepo 根 `package.json``workspaces: ["packages/*", ...]` 自动识别本包,但主 CLI 不会 import 它。

View File

@@ -0,0 +1,19 @@
{
"name": "cloud-artifacts",
"version": "0.0.0",
"private": true,
"description": "Cloudflare Worker + R2 HTML artifact host (POST /upload → hash URL)",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"setup": "bash scripts/setup.sh",
"cf-typegen": "wrangler types"
},
"dependencies": {
"nanoid": "^5.0.0"
},
"devDependencies": {
"typescript": "^6.0.0",
"wrangler": "^4.0.0"
}
}

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
BUCKET="${BUCKET:-cloud-artifacts}"
echo "==> Creating R2 bucket: $BUCKET"
npx wrangler r2 bucket create "$BUCKET" || echo "(already exists or creation deferred)"
echo "==> Adding lifecycle rule: prefix '7d/' -> expire after 7 days"
npx wrangler r2 bucket lifecycle add "$BUCKET" delete-7d "7d/" --expire-days 7 --force
echo "==> Adding lifecycle rule: prefix '30d/' -> expire after 30 days"
npx wrangler r2 bucket lifecycle add "$BUCKET" delete-30d "30d/" --expire-days 30 --force
echo "==> Setting secret TOKEN (paste value, then Enter)"
npx wrangler secret put TOKEN
cat <<'NEXT'
==> Done. Remaining manual steps:
1. Bind a custom domain to the Worker (POST + GET 都走 Worker单一域名):
Dashboard: Workers & Pages > cloud-artifacts > Settings > Domains & Routes > Add > Custom Domain
填入你的 domain如 artifacts.example.comCloudflare 会自动加 DNS 记录和 SSL。
2. Update wrangler.toml [vars] PUBLIC_URL 为上一步的 domain带 https://,如 https://artifacts.example.com
3. Deploy:
bun run deploy
NEXT

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# cloud-artifacts 端到端测试脚本
# 用法:
# WORKER_URL=https://cloud-artifacts.claude-code-best.workers.dev \
# TOKEN=cloud-artifacts \
# bash scripts/test.sh
#
# 如本机连不上 workers.dev可通过代理
# HTTPS_PROXY=http://127.0.0.1:7890 bash scripts/test.sh ...
set -uo pipefail
WORKER_URL="${WORKER_URL:-https://cloud-artifacts.claude-code-best.workers.dev}"
TOKEN="${TOKEN:-cloud-artifacts}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# 颜色
G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; D=$'\033[0m'
# 准备测试 html
echo '<!doctype html><title>t</title><h1>hello v1</h1>' > "$TMP/v1.html"
echo '<!doctype html><title>t</title><h1>hello v2 (overwritten)</h1>' > "$TMP/v2.html"
# 11MB 的 html用于 413 测试)
yes '<p>x</p>' | head -c 11000000 > "$TMP/big.html"
pass=0; fail=0
expect() {
local label="$1" want_code="$2" resp="$3" code="$4" body="$5"
if [[ "$code" == "$want_code" ]]; then
printf "${G}✓ %s -> HTTP %s${D}\n" "$label" "$code"
[[ -n "$resp" ]] && printf " body: %s\n" "$body"
pass=$((pass+1))
else
printf "${R}✗ %s -> HTTP %s (expected %s)${D}\n" "$label" "$code" "$want_code"
printf " body: %s\n" "$body"
fail=$((fail+1))
fi
}
call() {
local label="$1" want="$2"
shift 2
curl -sS -o "$TMP/resp" -w "%{http_code}" "$@" > "$TMP/code"
expect "$label" "$want" "" "$(cat "$TMP/code")" "$(cat "$TMP/resp")"
}
echo "===== 错误用例 ====="
# 1. 401 未授权
call "no token" 401 \
-X POST "$WORKER_URL/upload" \
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
# 2. 401 token 错
call "wrong token" 401 \
-X POST "$WORKER_URL/upload" \
-H "Authorization: Bearer wrong" \
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
# 3. 415 错误 MIME
call "wrong content-type" 415 \
-X POST "$WORKER_URL/upload" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" --data-binary '{"x":1}'
# 4. 400 invalid_ttl
call "ttl=999" 400 \
-X POST "$WORKER_URL/upload?ttl=999" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
# 5. 400 invalid_ttl (负数)
call "ttl=0" 400 \
-X POST "$WORKER_URL/upload?ttl=0" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
# 6. 400 invalid_hash
call "hash=bad/slash" 400 \
-X POST "$WORKER_URL/upload?hash=bad/slash" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
# 7. 413 payload_too_large (11MB > 10MB)
call "11MB body" 413 \
-X POST "$WORKER_URL/upload" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/html" --data-binary "@$TMP/big.html"
# 8. 404 not_found (错路径)
call "wrong path" 404 \
-X POST "$WORKER_URL/notupload" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
echo
echo "===== 成功用例 ====="
# 9. 200 随机 ID + 7 天(默认)
echo "--- 默认上传(随机 ID + 7 天)---"
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
-X POST "$WORKER_URL/upload" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
cat "$TMP/resp"; echo
RANDOM_ID=$(python3 -c "import json,sys;print(json.load(open('$TMP/resp'))['id'])" 2>/dev/null || echo "")
[[ -n "$RANDOM_ID" ]] && printf "${G}随机 ID: %s${D}\n" "$RANDOM_ID"
# 10. 200 自定义 hash + 30 天
echo "--- 自定义 hash + 30 天 ---"
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
-X POST "$WORKER_URL/upload?ttl=30&hash=test-artifact-v1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/html" --data-binary "@$TMP/v1.html"
cat "$TMP/resp"; echo
# 11. 覆盖(同 hash
echo "--- 覆盖:同 hash 上传 v2 ---"
curl -sS -o "$TMP/resp" -w "HTTP %{http_code}\n" \
-X POST "$WORKER_URL/upload?ttl=30&hash=test-artifact-v1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/html" --data-binary "@$TMP/v2.html"
cat "$TMP/resp"; echo
echo
echo "===== R2 写入验证(不走 CDN走 Cloudflare API ====="
# 用 wrangler r2 object get 验证文件实际写入了 R2
if [[ -n "$RANDOM_ID" ]]; then
echo "--- 验证随机 ID 文件存在: 7d/$RANDOM_ID.html ---"
npx wrangler r2 object get "cloud-artifacts/7d/$RANDOM_ID.html" --remote --file "$TMP/got.html" 2>&1 | tail -5
echo "下载内容:" ; cat "$TMP/got.html" 2>/dev/null
fi
echo "--- 验证覆盖后 test-artifact-v1 是 v2 内容 ---"
npx wrangler r2 object get "cloud-artifacts/30d/test-artifact-v1.html" --remote --file "$TMP/got2.html" 2>&1 | tail -5
echo "下载内容:" ; cat "$TMP/got2.html" 2>/dev/null
echo
echo "===== 汇总 ====="
printf "${G}pass=%d${D} ${R}fail=%d${D}\n" "$pass" "$fail"
[[ "$fail" -gt 0 ]] && exit 1 || exit 0

View File

@@ -0,0 +1,119 @@
import { nanoid } from 'nanoid'
// TOKEN 通过 `wrangler secret put TOKEN` 注入wrangler types 不为 secret 生成类型
// 所以这里显式扩展全局 Env与 worker-configuration.d.ts 合并)
declare global {
interface Env {
TOKEN: string
}
}
const HASH_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
const TTL_PREFIXES = ['7d', '30d']
const ALLOWED_TTLS = [7, 30]
const HTML_CONTENT_TYPE = 'text/html; charset=utf-8'
// GET /<prefix>/<id>.html —— prefix 与 lifecycle rule 对应,限制只能是 7d 或 30d
const GET_PATH_PATTERN = /^\/(7d|30d)\/([A-Za-z0-9_-]{1,128})\.html$/
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url)
if (req.method === 'GET') {
return handleGet(url, env)
}
if (url.pathname === '/upload' && req.method === 'POST') {
return handleUpload(req, env, url)
}
return json({ error: 'not_found' }, 404)
},
} satisfies ExportedHandler<Env>
// GET /7d/<id>.html 或 /30d/<id>.html —— 从 R2 读,返回 text/html
async function handleGet(url: URL, env: Env): Promise<Response> {
const match = GET_PATH_PATTERN.exec(url.pathname)
if (!match) {
return json({ error: 'not_found' }, 404)
}
const [, prefix, id] = match
const obj = await env.BUCKET.get(`${prefix}/${id}.html`)
if (obj === null) {
return new Response('Not Found', { status: 404 })
}
const headers = new Headers()
obj.writeHttpMetadata(headers)
headers.set('content-type', HTML_CONTENT_TYPE)
headers.set('cache-control', 'public, max-age=86400')
return new Response(obj.body, { headers, status: 200 })
}
async function handleUpload(
req: Request,
env: Env,
url: URL,
): Promise<Response> {
const auth = req.headers.get('authorization') ?? ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
if (!env.TOKEN || !token || token !== env.TOKEN) {
return json({ error: 'unauthorized' }, 401)
}
const contentType = (req.headers.get('content-type') ?? '').toLowerCase()
if (!contentType.startsWith('text/html')) {
return json({ error: 'unsupported_media_type' }, 415)
}
const maxBytes = Number.parseInt(env.MAX_BYTES, 10) || 10 * 1024 * 1024
const declaredLength = Number.parseInt(
req.headers.get('content-length') ?? '',
10,
)
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
return json({ error: 'payload_too_large' }, 413)
}
const defaultTtl = Number.parseInt(env.DEFAULT_TTL_DAYS, 10) || 7
const ttlParam = url.searchParams.get('ttl')
const ttl = ttlParam === null ? defaultTtl : Number.parseInt(ttlParam, 10)
if (!Number.isFinite(ttl) || !ALLOWED_TTLS.includes(ttl)) {
return json({ error: 'invalid_ttl' }, 400)
}
const hashParam = url.searchParams.get('hash')
let id: string
if (hashParam !== null) {
if (!HASH_PATTERN.test(hashParam)) {
return json({ error: 'invalid_hash' }, 400)
}
id = hashParam
// 覆盖:先删所有 ttl prefix 下可能的旧 keyR2 delete 不存在的 key 不报错)
await Promise.all(
TTL_PREFIXES.map(p => env.BUCKET.delete(`${p}/${id}.html`)),
)
} else {
id = nanoid(21)
}
const body = await req.arrayBuffer()
if (body.byteLength > maxBytes) {
return json({ error: 'payload_too_large' }, 413)
}
const key = `${ttl}d/${id}.html`
await env.BUCKET.put(key, body, {
httpMetadata: { contentType: HTML_CONTENT_TYPE },
})
const expiresAt = new Date(Date.now() + ttl * 24 * 60 * 60 * 1000)
return json(
{ id, url: `${env.PUBLIC_URL}/${key}`, expiresAt: expiresAt.toISOString() },
200,
)
}
function json(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true
},
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
name = "cloud-artifacts"
main = "src/index.ts"
compatibility_date = "2026-06-20"
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "cloud-artifacts"
[vars]
PUBLIC_URL = "https://artifacts.claude-code-best.win"
DEFAULT_TTL_DAYS = "7"
MAX_TTL_DAYS = "30"
MAX_BYTES = "10485760"
[observability]
enabled = true