Skip to content
Merged
2 changes: 2 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ knowledge_base:
applyTo: "src/**/*.ts"
- files: "docs/spec/free-plan-bindings-only.md"
applyTo: "cloudflare.config.ts"
- files: "docs/spec/shared-links-logged-to-kv-at-conversion.md"
applyTo: "cloudflare.config.ts"
5 changes: 3 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ better-link is a single Cloudflare Worker with three kinds of request. A convers
- `src/instagram.ts` fetches the `/embed/captioned/` page and parses it into a `Post` (`instagramPost`, `parseEmbed`). The `Post` and `PostCache` types live here.
- `src/facebook.ts` fetches `plugins/post.php` and `plugins/video.php` and parses them into a `Post` (`facebookPost`, `facebookVideo`).
- `src/og.ts` renders a `Post` into og and twitter card HTML (`renderOgPage`).
- `cloudflare.config.ts` holds the Worker settings that `cf` reads.
- `cloudflare.config.ts` holds the Worker settings that `cf` reads. It declares the `SHARED_LINKS` KV binding only when the build variable `LOG_SHARED_LINKS` is `1`.
- `src/shared-link-log.ts` (`logSharedLink`) writes one record per successful `GET /?url=` to `env.SHARED_LINKS` through `ctx.waitUntil`. Without the binding it does nothing.

## Invariants

Expand All @@ -23,4 +24,4 @@ The rules a change must not break are in `docs/spec/`. Each spec's `scope` names
## Cross-cutting concerns

- Caching: `instagram.ts` and `facebook.ts` store parsed posts with the Cache API through `PostCache`. `createWorker({ cache })` takes the cache as a dependency.
- Testing: tests pass a fake cache from `test/support/fake-cache.ts` and replace `fetch` with `spyOn(globalThis, "fetch")`. Upstream pages are saved under `test/fixtures/`, and every parsing test runs against them without the network.
- Testing: tests pass a fake cache from `test/support/fake-cache.ts` and a fake KV binding from `test/support/fake-kv.ts`, and replace `fetch` with `spyOn(globalThis, "fetch")`. Upstream pages are saved under `test/fixtures/`, and every parsing test runs against them without the network.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ bun run smoke # starts cf dev on port 8799 and sends end-to-end requests
```

- `.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.
- 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 by default, so a fresh copy deploys with no setup. The KV binding for shared-link logs is opt-in through the build variable `LOG_SHARED_LINKS=1` (see README); do not declare it unconditionally.
- `bun.lock` stays at `lockfileVersion` 1 so Workers Builds' default Bun 1.2.15 can install it (checked by `test/config.test.ts`); regenerate it with Bun 1.3.
- Pull requests are reviewed by CodeRabbit (GitHub App), configured in `.coderabbit.yaml`. Its `knowledge_base` applies `docs/spec/*.md` to the files in their `scope`; when a spec's `scope` gains a path, add it there.
- 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.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ Click the button to deploy your own copy to Cloudflare:

Cloudflare copies this repository into your GitHub or GitLab account, builds it and deploys the Worker, and connects Workers Builds so that every push to the copy redeploys it. You only need a Cloudflare account; the Workers free plan is enough, and there are no bindings, variables or secrets to fill in. You can rename the Worker on the setup page.

Optional: to record each share link that `GET /?url=` produces, set the build variable `LOG_SHARED_LINKS` to `1` in Workers Builds. The config then declares a KV binding named `SHARED_LINKS`, and each record expires after 90 days. Without the variable no binding is declared and the Worker behaves as before. Unset the variable and redeploy to remove the binding; the namespace and its records stay.

Once deployed, the Worker is reachable at `https://better-link.<your-subdomain>.workers.dev` (or under the name you picked). Use that as `https://link.example` in the examples above, or attach a custom domain in the Cloudflare dashboard.

To deploy manually instead:
Expand All @@ -84,6 +86,8 @@ npx cf auth login # once; cf does not reuse a Wrangler login
bun run deploy # cf deploy
```

If you use the shared-link log, run `LOG_SHARED_LINKS=1 bun run deploy` instead. A manual deploy without the variable removes the binding.

## Development

Requires [Bun](https://bun.sh) and Node.js 22.18 or later.
Expand All @@ -95,6 +99,6 @@ bun run check # typecheck + unit tests
bun run smoke # end-to-end tests against real Instagram / Facebook, needs network
```

Configuration lives in `cloudflare.config.ts`. 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 by default; the optional shared-link log above is the only binding the config can declare.

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).
6 changes: 5 additions & 1 deletion README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ curl 'https://link.example/?url=https%3A%2F%2Fwww.instagram.com%2Fp%2FABC%2F%3Fi

Cloudflare 會把這個 repo 複製到你的 GitHub 或 GitLab 帳號、建置並部署 Worker,並接上 Workers Builds,之後推到那份複本就會自動重新部署。只需要 Cloudflare 帳號,Workers 免費方案就夠,也沒有 binding、環境變數或 secret 要填。Worker 名稱可以在設定頁改。

可選功能:要記錄 `GET /?url=` 產生的每一筆分享連結,在 Workers Builds 的建置變數設定 `LOG_SHARED_LINKS` 為 `1`。設定檔會宣告一個名為 `SHARED_LINKS` 的 KV binding,每筆紀錄 90 天後過期。沒有設定變數時不宣告任何 binding,Worker 的行為和以前相同。要取消時移除變數並重新部署即可移除 binding;命名空間和其中的紀錄會保留。

部署完成後網址是 `https://better-link.<你的子網域>.workers.dev`(或你取的名稱),把上面範例中的 `https://link.example` 換成它即可,也可以在 Cloudflare 後台綁自訂網域。

手動部署:
Expand All @@ -84,6 +86,8 @@ npx cf auth login # 只需一次;cf 不沿用 Wrangler 的登入
bun run deploy # cf deploy
```

有啟用分享連結紀錄時,改用 `LOG_SHARED_LINKS=1 bun run deploy`。手動部署時少了這個變數,binding 就會被移除。

## 開發

需要 [Bun](https://bun.sh) 和 Node.js 22.18 以上。
Expand All @@ -95,6 +99,6 @@ bun run check # 型別檢查 + 單元測試
bun run smoke # 對真實 Instagram / Facebook 的端到端測試,需要網路
```

設定在 `cloudflare.config.ts`。專案只用 Workers 免費方案的功能,不需要任何 binding。
設定在 `cloudflare.config.ts`。專案只用 Workers 免費方案的功能,預設不需要任何 binding;上面的分享連結紀錄是設定檔唯一可以宣告的 binding。

設計約束寫在 [`docs/spec/`](docs/spec/),例如每請求 CPU 10ms 上限、失敗時退回原網址、og 標籤不直接放會過期的 CDN 網址。修改程式前先讀相關規格;給 AI 代理的說明在 [`CLAUDE.md`](CLAUDE.md)(`AGENTS.md` 是它的 symlink)。
5 changes: 4 additions & 1 deletion cloudflare.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { defineConfig } from "cf/config";
import { bindings, defineConfig } from "cf/config";

const buildEnv = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;

export default defineConfig({
worker: {
Expand All @@ -8,5 +10,6 @@ export default defineConfig({
observability: {
enabled: true,
},
...(buildEnv?.LOG_SHARED_LINKS === "1" ? { env: { SHARED_LINKS: bindings.kv() } } : {}),
},
});
2 changes: 1 addition & 1 deletion docs/spec/fail-open-to-original.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ status: accepted
scope:
- "src/**"
verify: check:bun test ./test/fail-open-to-original.test.ts
related: [cpu-budget-10ms, tracking-params-cleaned-at-conversion]
related: [cpu-budget-10ms, tracking-params-cleaned-at-conversion, shared-links-logged-to-kv-at-conversion]
source: better-link-design-spec
adr: null
---
Expand Down
6 changes: 3 additions & 3 deletions docs/spec/free-plan-bindings-only.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ scope:
- "cloudflare.config.ts"
- "src/**"
verify: check:bun test ./test/config.test.ts -t "storage.binding"
related: [cpu-budget-10ms, signed-cdn-urls-never-outlive-signature]
related: [cpu-budget-10ms, signed-cdn-urls-never-outlive-signature, shared-links-logged-to-kv-at-conversion]
source: better-link-design-spec
adr: null
---
只使用 Workers 免費方案內含的功能,不宣告 KV、R2、D1、Durable Objects、Queues 或任何付費 binding。
只使用 Workers 免費方案內含的功能,不宣告 KV、R2、D1、Durable Objects、Queues 或任何付費 binding;唯一的例外是部署的人用建置變數啟用、用來紀錄分享連結的一個 KV 命名空間,它是可選功能,寫法由 `shared-links-logged-to-kv-at-conversion` 規定。

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

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

Expand Down
21 changes: 21 additions & 0 deletions docs/spec/shared-links-logged-to-kv-at-conversion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
id: shared-links-logged-to-kv-at-conversion
status: accepted
scope:
- "cloudflare.config.ts"
- "src/index.ts"
- "src/shared-link-log.ts"
verify: check:bun test ./test/shared-links-logged-to-kv-at-conversion.test.ts
related: [free-plan-bindings-only, fail-open-to-original, tracking-params-cleaned-at-conversion]
source: null
adr: null
---
紀錄分享連結是可選功能:預設不啟用,啟用後只在 `GET /?url=` 成功產生分享連結時寫進一個 Workers KV 命名空間,`raw=1` 的請求同樣會記錄(並在 metadata 標記 `raw`),每一筆都帶 `expirationTtl`,而且寫入成功與否都不能改變回應。

`cloudflare.config.ts` 只在建置時的環境變數有設定時才宣告 `bindings.kv()`,部署的人在 Workers Builds 的建置變數裡設定它來啟用;在 Cloudflare 後台手動加的綁定會在下次部署時被設定檔覆蓋,所以不能當作啟用方式。預設匯出必須維持一般物件,因為 `test/config.test.ts` 直接讀它。Worker 在執行時看 `env` 有沒有這個綁定來決定要不要寫。寫入放在 `ctx.waitUntil` 裡,所以 `src/index.ts` 的 `createWorker` 要把 `env` 和 `ctx` 傳下去。key 以時間戳開頭,因為 KV 列出 key 時依 UTF-8 位元組排序,這樣一次 list 就是時間順序。清過追蹤碼的原網址和時間放在 key 的 metadata(上限 1,024 bytes,以 UTF-8 位元組計算),list 回來就能直接讀,不用逐筆 get;超過上限時 metadata 只留時間,原網址仍放在 value,`raw=1` 的紀錄則在 metadata 加上 `raw: true`。舊資料靠 TTL 自動淘汰,不另寫清理機制。

分享連結轉址 `GET /<原網域>/<路徑>` 和 `/media/` 不寫紀錄,所以手動把 `https://` 換成 Worker 網址產生的連結不會被記到,這是接受的代價。沒有綁定 KV 時 Worker 完全不寫入,行為和沒有這個功能時相同,所以用 Deploy 按鈕產生的新部署仍然不需要任何設定。紀錄包含所有使用這個部署的人的連結;怎麼讀取紀錄、誰能讀取,不在這條規格裡。

違反時不會被察覺:KV 免費方案每天只能寫 1,000 次,若在轉址端點記錄,爬蟲抓預覽和收件人點開會在流量高的某天吃光額度,之後的寫入默默失敗;沒帶 TTL 的紀錄會一直累積到 1 GB 才開始寫不進去;寫入若在回應路徑上被等待而拋錯,爬蟲拿到錯誤只會默默不顯示預覽。

由 `test/shared-links-logged-to-kv-at-conversion.test.ts` 檢查(即 `verify` 指令):只有 `GET /?url=`(含 `raw=1`)會呼叫 `put` 且帶 `expirationTtl`;metadata 超過 1,024 bytes 時不含原網址,且以位元組而非字元計算;`cloudflare.config.ts` 只在 `LOG_SHARED_LINKS=1` 時宣告 `SHARED_LINKS` 綁定;沒有綁定時不排程任何寫入;`put` 同步拋錯、非同步拒絕或 `waitUntil` 拋錯時,回應與沒有綁定時相同;`put` 永不結束時回應也不延遲;分享連結轉址與失敗的轉換都不寫入。
2 changes: 1 addition & 1 deletion docs/spec/tracking-params-cleaned-at-conversion.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ status: accepted
scope:
- "src/**"
verify: check:bun test ./test/clean.test.ts ./test/expand.test.ts
related: [fail-open-to-original]
related: [fail-open-to-original, shared-links-logged-to-kv-at-conversion]
source: better-link-design-spec
adr: null
---
Expand Down
14 changes: 9 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { facebookPost, facebookVideo, isFacebookPostUrl, isFacebookVideoUrl } fr
import { fixServiceUrl } from "./fix-services";
import { instagramPost, isShortcode, shortcodeOfMediaId, type Post, type PostCache } from "./instagram";
import { renderOgPage } from "./og";
import { logSharedLink } from "./shared-link-log";

const CONVERT_HINT = "pass a percent-encoded http(s) URL as ?url=";
const POST_PATH = /^\/(p|reel)\/([^/]+)(?:\/([1-9]\d?))?\/?$/;
Expand Down Expand Up @@ -40,7 +41,7 @@ function text(status: number, body: string): Response {
});
}

async function convert(requestUrl: URL): Promise<Response> {
async function convert(requestUrl: URL, env: Env, ctx: ExecutionContext): Promise<Response> {
const raw = requestUrl.searchParams.get("url");
if (raw === null || raw === "") return text(400, CONVERT_HINT);
let target: URL;
Expand All @@ -51,8 +52,11 @@ async function convert(requestUrl: URL): Promise<Response> {
}
if (!isShareable(target)) return text(400, CONVERT_HINT);
const cleaned = cleanUrl((await expandShareLink(target)) ?? target);
const prefix = requestUrl.searchParams.get("raw") === "1" ? "https://" : `${requestUrl.origin}/`;
return text(200, `${prefix}${cleaned.host}${cleaned.pathname}${cleaned.search}`);
const rawMode = requestUrl.searchParams.get("raw") === "1";
const prefix = rawMode ? "https://" : `${requestUrl.origin}/`;
const link = `${cleaned.host}${cleaned.pathname}${cleaned.search}`;
logSharedLink(env, ctx, `https://${link}`, rawMode);
return text(200, `${prefix}${link}`);
}

async function loadPost(
Expand Down Expand Up @@ -198,9 +202,9 @@ export function createWorker(deps: { cache: () => PostCache }): {
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
} {
return {
async fetch(request, _env, _ctx) {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/") return convert(url);
if (url.pathname === "/") return convert(url, env, ctx);
if (url.pathname.startsWith("/media/")) return mediaRedirect(url, deps);
return shareRedirect(url, request.headers.get("User-Agent"), deps);
},
Expand Down
32 changes: 32 additions & 0 deletions src/shared-link-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const SHARED_LINK_TTL_SECONDS = 7_776_000;
const METADATA_LIMIT_BYTES = 1_024;

type SharedLinkStore = {
put(key: string, value: string, options: { expirationTtl: number; metadata: object }): Promise<void>;
};

function metadataFor(url: string, at: string, raw: boolean): object {
const base = raw ? { at, raw: true } : { at };
const full = { url, ...base };
const size = new TextEncoder().encode(JSON.stringify(full)).byteLength;
return size <= METADATA_LIMIT_BYTES ? full : base;
}

export function logSharedLink(env: Env, ctx: ExecutionContext, url: string, raw: boolean): void {
const store = (env as { SHARED_LINKS?: SharedLinkStore }).SHARED_LINKS;
if (store === undefined) return;
const at = new Date().toISOString();
const metadata = metadataFor(url, at, raw);
const write = async () => {
try {
await store.put(`${at}-${crypto.randomUUID()}`, url, { expirationTtl: SHARED_LINK_TTL_SECONDS, metadata });
} catch {
// Logging must never change the conversion response.
}
};
try {
ctx.waitUntil(write());
} catch {
// Same reason as above: a failing waitUntil is not the caller's problem.
}
}
Loading
Loading