Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
# cf needs Node 22.18 or later; 24 matches the Workers Builds image.
node-version: 24
- uses: oven-sh/setup-bun@v2
with:
# bun.lock must stay at lockfileVersion 1; Bun 1.3 reads and writes that version.
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.obsidian.yaml
node_modules/
.wrangler/
worker-configuration.d.ts
.cloudflare/
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,24 @@ better-link is a Cloudflare Worker that turns social media post URLs into share

## Commands

Bun is used for package management and tests; wrangler runs the Worker.
Bun is used for package management and tests; the `cf` CLI runs, builds and deploys the Worker from `cloudflare.config.ts`, delegating the build to Wrangler. `cf` runs on Node 22.18 or later.

```sh
bun install
bun run dev # wrangler dev, run the Worker locally
bun run typecheck # wrangler types generates worker-configuration.d.ts, then checks src and test separately
bun run dev # cf dev, run the Worker locally
bun run typecheck # cf workers types generates .cloudflare/types/index.d.ts, then checks src and test separately
bun test # all unit tests
bun test ./test/clean.test.ts # a single file
bun run check # typecheck + test, run this before committing
bun run deploy # wrangler deploy
bun run smoke # starts wrangler dev on 127.0.0.1:8799 and sends end-to-end requests to real Instagram / Facebook
bun run deploy # cf deploy
bun run smoke # starts cf dev on port 8799 and sends end-to-end requests to real Instagram / Facebook
```

- `worker-configuration.d.ts` is generated by `wrangler types` and gitignored. Until typecheck has run, types such as `Env` and `ExecutionContext` are missing.
- The README's Deploy to Cloudflare button relies on `wrangler.jsonc` at the repo root, the `deploy` script and the `cloudflare` field in `package.json`. Keep it working without any bindings, variables or secrets, so a fresh copy deploys with no setup.
- `.cloudflare/` holds generated types and build output and is gitignored. Until typecheck has run, types such as `Env` and `ExecutionContext` are missing. `wrangler.config.ts` sets `types.generate: false`, so `cf dev` and `cf build` do not regenerate them.
- The README's Deploy to Cloudflare button relies on `cloudflare.config.ts`, the `deploy` script and the `cloudflare` field in `package.json`. Do not add a Wrangler config file; `test/config.test.ts` fails if one appears. Keep it working without any bindings, variables or secrets, so a fresh copy deploys with no setup.
- `bun.lock` must stay at `lockfileVersion` 1. Workers Builds installs with Bun 1.2.15 by default, which rejects version 2 under `--frozen-lockfile`; Bun 1.4 reads version 1 without rewriting it but writes version 2 when it regenerates the lock. `test/config.test.ts` fails if that happens; regenerate the lock with Bun 1.3.
- Pull requests are reviewed by CodeRabbit (GitHub App), configured in `.coderabbit.yaml`. Its path instructions restate the specs; update them when specs are added or removed.
- GitHub Actions (`.github/workflows/ci.yml`) runs `bun install --frozen-lockfile` and `bun run check` with Bun 1.3.11 on pull requests and pushes to `main`. Keep its Bun version on 1.3 so the lock stays at version 1.
- GitHub Actions (`.github/workflows/ci.yml`) runs `bun install --frozen-lockfile` and `bun run check` with Bun 1.3.11 and Node 24 on pull requests and pushes to `main`. Keep its Bun version on 1.3 so the lock stays at version 1.
- `smoke` needs outbound network access and its result depends on what upstream returns at the time, so it is not suitable as a required CI check.

## Architecture
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,13 @@ To deploy manually instead:

```sh
bun install
bun run deploy # wrangler deploy, logs in to Cloudflare on first run
npx cf auth login # once; cf does not reuse a Wrangler login
bun run deploy # cf deploy
```

## Development

Requires [Bun](https://bun.sh).
Requires [Bun](https://bun.sh) and Node.js 22.18 or later.

```sh
bun install
Expand All @@ -94,6 +95,6 @@ bun run check # typecheck + unit tests
bun run smoke # end-to-end tests against real Instagram / Facebook, needs network
```

Configuration lives in `wrangler.jsonc`. The project only uses features of the Workers free plan and needs no bindings.
Configuration lives in `cloudflare.config.ts`. The project only uses features of the Workers free plan and needs no bindings.

Design constraints are written in [`docs/spec/`](docs/spec/) (in Traditional Chinese), e.g. the 10ms CPU limit per request, falling back to the original URL on failure, and never putting expiring CDN URLs directly in og tags. Read the relevant specs before changing code. Instructions for AI agents are in [`CLAUDE.md`](CLAUDE.md) (`AGENTS.md` is a symlink to it).
7 changes: 4 additions & 3 deletions README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,13 @@ Cloudflare 會把這個 repo 複製到你的 GitHub 或 GitLab 帳號、建置

```sh
bun install
bun run deploy # wrangler deploy,第一次會要求登入 Cloudflare
npx cf auth login # 只需一次;cf 不沿用 Wrangler 的登入
bun run deploy # cf deploy
```

## 開發

需要 [Bun](https://bun.sh)。
需要 [Bun](https://bun.sh) 和 Node.js 22.18 以上。

```sh
bun install
Expand All @@ -94,6 +95,6 @@ bun run check # 型別檢查 + 單元測試
bun run smoke # 對真實 Instagram / Facebook 的端到端測試,需要網路
```

設定在 `wrangler.jsonc`。專案只用 Workers 免費方案的功能,不需要任何 binding。
設定在 `cloudflare.config.ts`。專案只用 Workers 免費方案的功能,不需要任何 binding。

設計約束寫在 [`docs/spec/`](docs/spec/),例如每請求 CPU 10ms 上限、失敗時退回原網址、og 標籤不直接放會過期的 CDN 網址。修改程式前先讀相關規格;給 AI 代理的說明在 [`CLAUDE.md`](CLAUDE.md)(`AGENTS.md` 是它的 symlink)。
8 changes: 3 additions & 5 deletions docs/spec/free-plan-bindings-only.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,16 @@
id: free-plan-bindings-only
status: accepted
scope:
- "wrangler.toml"
- "wrangler.json"
- "wrangler.jsonc"
- "cloudflare.config.ts"
- "src/**"
verify: check:test -f wrangler.jsonc && ! grep -qE 'kv_namespaces|r2_buckets|d1_databases|durable_objects|queues' wrangler.jsonc
verify: check:bun test ./test/config.test.ts -t "storage.binding"
related: [cpu-budget-10ms, signed-cdn-urls-never-outlive-signature]
source: better-link-design-spec
adr: null
---
只使用 Workers 免費方案內含的功能,不宣告 KV、R2、D1、Durable Objects、Queues 或任何付費 binding。

需要暫存的資料一律用 Cache API(`caches.default` 的 `put` 和 `match`)。wrangler 設定檔裡不出現任何儲存類 binding。
需要暫存的資料一律用 Cache API(`caches.default` 的 `put` 和 `match`)。`cloudflare.config.ts` 裡不出現任何儲存類 binding。

環境變數和 secrets 不算 binding,可以使用。Cache API 只存在寫入它的資料中心、不會同步到其他地方,這是接受的代價,不是要修的問題。

Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "wrangler types && tsc -p tsconfig.json && tsc -p tsconfig.test.json",
"dev": "cf dev",
"deploy": "cf deploy",
"typecheck": "cf workers types && tsc -p tsconfig.json && tsc -p tsconfig.test.json",
"check": "bun run typecheck && bun test",
"smoke": "bash scripts/smoke.sh"
},
Expand Down
6 changes: 3 additions & 3 deletions scripts/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ cd "$(dirname "$0")/.."
mkdir -p .wrangler/home
export HOME="$PWD/.wrangler/home"

./node_modules/.bin/wrangler --version
./node_modules/.bin/cf --version

mkdir -p .wrangler
WRANGLER_SEND_METRICS=false ./node_modules/.bin/wrangler dev --ip 127.0.0.1 --port 8799 >.wrangler/smoke.log 2>&1 &
WRANGLER_SEND_METRICS=false ./node_modules/.bin/cf dev --port 8799 >.wrangler/smoke.log 2>&1 &
pid=$!
tmp=$(mktemp -d)
cleanup() {
Expand All @@ -28,7 +28,7 @@ for _ in $(seq 1 60); do
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "wrangler dev did not become ready" >&2
echo "cf dev did not become ready" >&2
tail -n 80 .wrangler/smoke.log >&2 || true
exit 1
fi
Expand Down
35 changes: 20 additions & 15 deletions test/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
import { expect, test } from "bun:test";
import { bindings, exports, type WorkerConfig } from "cf/config";
import config from "../cloudflare.config";

const STORAGE_BINDING = /kv_namespaces|r2_buckets|d1_databases|durable_objects|queues/;
const STORAGE_BINDING = /"type":"(kv|r2|d1|durable-object|queue)"/;

test("wrangler config names a main module and declares no storage binding", async () => {
const text = await Bun.file(new URL("../wrangler.jsonc", import.meta.url)).text();
expect(text).toContain('"main"');
expect(text.match(STORAGE_BINDING)).toBeNull();
test("cf config names an entrypoint and declares no storage binding", () => {
expect(config.worker.entrypoint).toBe("src/index.ts");
expect(JSON.stringify(config).match(STORAGE_BINDING)).toBeNull();
});

test("the storage-binding pattern matches a known-bad sample", () => {
const sample = '{"kv_namespaces":[{"binding":"CACHE","id":"x"}]}';
expect(sample.match(STORAGE_BINDING)).not.toBeNull();
test("the storage-binding pattern matches every storage binding cf/config can build", () => {
const samples = [bindings.kv(), bindings.r2(), bindings.d1(), bindings.queue(), exports.durableObject({ storage: "sqlite" })];
for (const sample of samples) {
expect(JSON.stringify({ worker: { env: { X: sample } } }).match(STORAGE_BINDING)).not.toBeNull();
}
});

test("wrangler config records every request in Workers Logs", async () => {
const config = JSON.parse(await Bun.file(new URL("../wrangler.jsonc", import.meta.url)).text());
expect(config.observability.enabled).toBe(true);
expect(config.observability.head_sampling_rate ?? 1).toBe(1);
test("cf config records every request in Workers Logs", () => {
const observability: WorkerConfig["observability"] = config.worker.observability;
expect(observability?.enabled).toBe(true);
expect(observability?.headSamplingRate ?? 1).toBe(1);
expect(observability?.logs?.headSamplingRate ?? 1).toBe(1);
});

test("wrangler config is only wrangler.jsonc", async () => {
expect(await Bun.file(new URL("../wrangler.toml", import.meta.url)).exists()).toBe(false);
expect(await Bun.file(new URL("../wrangler.json", import.meta.url)).exists()).toBe(false);
test("no wrangler config file sits beside cloudflare.config.ts", async () => {
for (const name of ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]) {
expect(await Bun.file(new URL(`../${name}`, import.meta.url)).exists()).toBe(false);
}
});

test("bun.lock stays at lockfile version 1 so Workers Builds' default Bun can install it", async () => {
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true
},
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
"include": [".cloudflare/types/index.d.ts", "src/**/*.ts"]
}
2 changes: 1 addition & 1 deletion tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"compilerOptions": {
"types": ["bun"]
},
"include": ["worker-configuration.d.ts", "src/**/*.ts", "test/**/*.ts"]
"include": [".cloudflare/types/index.d.ts", "src/**/*.ts", "test/**/*.ts"]
}
9 changes: 0 additions & 9 deletions wrangler.jsonc

This file was deleted.

Loading