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
34 changes: 6 additions & 28 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,38 +17,16 @@ reviews:
- "!test/fixtures/**"

path_instructions:
- path: "src/**/*.ts"
instructions: >-
Read CLAUDE.md and the specs in docs/spec/ whose `scope` covers this file before reviewing.
Flag violations of: CPU <= 10ms and <= 50 subrequests per request (parse with indexOf, no DOM,
no image or video processing); fail-open (any fetch or parse failure on a share link endpoint
must 302 to the cleaned original URL, never an error page or an og page missing media);
fix-service domains appearing anywhere but src/fix-services.ts; KV / R2 / D1 / Durable Objects /
Queues usage instead of caches.default; CDN URLs in og tags or cache lifetimes for CDN URLs
longer than 24 hours; tracking-parameter rules outside src/clean.ts.
Also flag upstream HTML parsing that does not return null when a field is missing.
- path: "test/**/*.ts"
instructions: >-
Tests must not hit the network: fetch is replaced with spyOn(globalThis, "fetch") and upstream
pages come from test/fixtures/. Flag new behavior in src/ without a matching test.
- path: "wrangler.jsonc"
instructions: >-
Only Workers free plan features. Flag any binding (kv_namespaces, r2_buckets, d1_databases,
durable_objects, queues). The README's Deploy to Cloudflare button must keep working with no
bindings, variables or secrets.
- path: "README*.md"
instructions: >-
README.md (English) and README.zh-TW.md (Traditional Chinese) must stay in sync; flag a change
to one without the matching change to the other. Fix-service domains must not be written out.
- path: "docs/spec/*.md"
instructions: >-
Specs are in Traditional Chinese with frontmatter id, status, scope, verify, related, source, adr.
Body order: the rule in one sentence, details and measurements with date and sample size,
out of scope, why a violation would go unnoticed. Flag when CLAUDE.md's spec list is not updated.
Fix-service domains must not be written out.

knowledge_base:
code_guidelines:
enabled: true
# Guideline files apply only to their own directory unless mapped with applyTo.
filePatterns:
- "CLAUDE.md"
- "docs/spec/*.md"
- files: "docs/spec/*.md"
applyTo: "src/**/*.ts"
- files: "docs/spec/free-plan-bindings-only.md"
applyTo: "cloudflare.config.ts"
26 changes: 26 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Architecture

## Bird's-eye view

better-link is a single Cloudflare Worker with three kinds of request. A conversion (`GET /?url=`) returns a cleaned share link. A share link (`GET /<original host>/<path>`) sends people to the cleaned original URL and gives crawlers a preview. A media request (`GET /media/...`) redirects to a freshly signed CDN URL. For Instagram and Facebook the Worker fetches and parses the post itself; for other platforms in the fix-service table it hands crawlers to a fix service.

## Codemap

- `src/index.ts` is the only entry point. `createWorker` routes `/` to `convert`, paths under `/media/` to `mediaRedirect`, and every other path to `shareRedirect`.
- `src/clean.ts` removes tracking parameters (`cleanUrl`).
- `src/expand.ts` expands `/share/` short links (`expandShareLink`) and decides which URLs can be converted (`isShareable`).
- `src/crawler.ts` tells crawlers from people by User-Agent (`isCrawler`).
- `src/fix-services.ts` maps other platforms to fix services (`fixServiceUrl`).
- `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.

## Invariants

The rules a change must not break are in `docs/spec/`. Each spec's `scope` names the files it constrains.

## 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.
35 changes: 5 additions & 30 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CLAUDE.md

better-link is a Cloudflare Worker that turns social media post URLs into share links with tracking parameters removed and working previews in chat apps such as Discord and Telegram. For Instagram and Facebook the Worker fetches the data itself and renders an og page; other platforms are handed off to existing fix services (fixupx, etc.).
better-link is a Cloudflare Worker that turns social media post URLs into clean share links with working previews in chat apps. `README.md` covers what it does, its endpoints and how to deploy it.

## Commands

Expand All @@ -19,43 +19,18 @@ 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.
- `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.
- `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.
- `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

`src/index.ts` is the only entry point and splits into three endpoints by path:

| Path | Handler | Behavior |
| --- | --- | --- |
| `GET /?url=<percent-encoded>` | `convert` | Expands FB/IG `/share/` short links, strips tracking parameters, returns the share link as `text/plain`. With `raw=1` returns the cleaned original URL instead |
| `GET /<original host>/<path>` | `shareRedirect` | Non-crawlers: 302 to the cleaned original URL. Crawlers: og HTML for IG posts and FB posts/videos; 302 to the fix service for platforms in the fix-service table; 302 to the original URL otherwise |
| `GET /media/...` | `mediaRedirect` | Re-fetches a signed CDN URL on the spot and returns a 302 with `Cache-Control: no-store` |

Module responsibilities:

- `clean.ts`: tracking parameter removal. Platforms the Worker supports itself (Instagram, YouTube) use an allowlist, others use a denylist; X/Twitter additionally drops `s` and `t`.
- `expand.ts`: `/share/` short link expansion (HEAD with a `Go-http-client/1.1` UA, at most 3 hops, 5 seconds) and `isShareable`.
- `crawler.ts`: detects crawlers by UA substring.
- `fix-services.ts`: the single mapping from other platforms to fix services.
- `instagram.ts`: fetches the `/embed/captioned/` page, parses it into a `Post`, and caches it with the Cache API.
- `facebook.ts`: fetches `plugins/post.php` / `plugins/video.php` and parses the post image or video.
- `og.ts`: renders a `Post` into og / twitter card HTML.

`createWorker({ cache })` lets tests inject a fake cache (`test/support/fake-cache.ts`); fetches are replaced in tests with `spyOn(globalThis, "fetch")`. Upstream pages are saved as HTML under `test/fixtures/`, and all parsing tests run against fixtures without hitting the network.
`ARCHITECTURE.md` maps the modules, how a request is routed, and how tests replace the cache and `fetch`.

## Specs (`docs/spec/`)

`docs/spec/*.md` are accepted hard constraints. Read the relevant specs before changing files covered by their `scope`. Each spec's frontmatter has a `verify` field with a runnable check (the part after `check:`). Current specs:

- `cpu-budget-10ms`: CPU ≤ 10ms and ≤ 50 subrequests per request. Parse with `indexOf` to pull only the needed fields; no DOM, no image or video processing in the Worker. Local `wrangler dev` does not enforce the CPU limit, so overruns only show up after deployment.
- `fail-open-to-original`: when fetching or parsing fails, share link endpoints always 302 to the cleaned original URL. Never return an error page or an og page missing its media. Facebook failure pages also return 200, so failure can only be detected by missing fields.
- `fix-service-table-single-source`: fix service domains live only in `src/fix-services.ts` and must not be hard-coded anywhere else.
- `free-plan-bindings-only`: Workers free plan only. Do not declare KV / R2 / D1 / Durable Objects / Queues; use `caches.default` for all caching.
- `signed-cdn-urls-never-outlive-signature`: og tags contain only this service's `/media/...` URLs, never CDN URLs. The cache lifetime of anything containing CDN URLs (currently 24 hours) must not be extended.
- `tracking-params-cleaned-at-conversion`: cleaning rules live only in `clean.ts`, and every original URL we 302 to goes through the same rules.
`docs/spec/*.md` are accepted hard constraints. Read the relevant specs before changing files covered by their `scope`. Each spec's frontmatter has a `verify` field with a runnable check (the part after `check:`).

What these specs have in common is that violations go unnoticed: a crawler that gets an error just silently shows no preview, and nobody reports it. So new behavior needs tests; do not rely on pasting links by hand and looking at the preview.

Expand Down
8 changes: 4 additions & 4 deletions docs/spec/cpu-budget-10ms.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ id: cpu-budget-10ms
status: accepted
scope:
- "src/**"
verify: null
verify: check:bun test ./test/facebook.test.ts ./test/facebook-video.test.ts ./test/instagram.test.ts -t "10 ms|cpu budget|timing"
related: [free-plan-bindings-only, fail-open-to-original]
source: better-link-design-spec
adr: null
---
每個請求的 CPU 時間必須在 10ms 以內,subrequest 不超過 50 個。

等待 `fetch()` 不算 CPU 時間,真正花 CPU 的是解析上游頁面,例如約 300KB 的 Instagram embed 頁。解析只取需要的欄位:Instagram 單圖取 `EmbeddedMediaImage` 的 `src`、`UsernameText`、`Caption`;影片和多圖取頁尾 `contextJSON`(約在 250KB 處,用 `indexOf` 定位後解碼);Facebook 取 `hd_src`、`sd_src`。不建完整 DOM,也不在 Worker 裡處理圖片或影片。2026-09-28 部署實測,冷抓取加解析約 285KB 的 embed 頁 CPU 為 1–2 ms。
等待 `fetch()` 不算 CPU 時間,真正花 CPU 的是解析上游頁面,例如約 300KB 的 Instagram embed 頁。解析只取需要的欄位:Instagram 單圖取 `EmbeddedMediaImage` 的 `src`、`UsernameText`、`Caption`;影片和多圖取頁尾 `contextJSON`(約在 250KB 處,用 `indexOf` 定位後解碼);Facebook 取 `hd_src`、`sd_src`。不建完整 DOM,也不在 Worker 裡處理圖片或影片。2026-09-28 部署實測,冷抓取加解析約 285KB 的 embed 頁 CPU 為 1–2 ms。2026-09-28 至 29 在正式環境用 `wrangler tail` 量冷抓取,Facebook 貼文最高 9 ms、Instagram 大型多圖最高 8 ms,其他 1–4 ms(樣本數未記錄),餘裕很小。

限制來源是 https://developers.cloudflare.com/workers/platform/limits/ 。拼圖和影片代理因此不在範圍內;新功能若需要在 Worker 裡做影像處理或大量文字處理,要先寫 ADR 重新決定。

違反時不會被察覺:本機 `wrangler dev` 沒有免費方案的 CPU 上限,超標的程式在開發時完全正常。部署後超標的請求會失敗,而爬蟲拿到失敗只是不顯示預覽,沒有人會回報。
違反時不會被察覺:本機 `cf dev` 沒有免費方案的 CPU 上限,超標的程式在開發時完全正常。部署後超標的請求會失敗,而爬蟲拿到失敗只是不顯示預覽,沒有人會回報。

這條目前沒有可執行的 verify,因為 CPU 時間只能從 Workers Logs 觀察(設計文件驗收第 4 步)。存下上游頁面當 fixture 後,可以加本機計時測試當警示,但本機 CPU 時間和 Cloudflare 上的不同,不能取代 Workers Logs。
verify 跑的是對 fixture 的本機計時測試:Facebook 解析的中位數超過 10ms 就失敗,Instagram 超過 2ms 只印警告。這只是警示,本機 CPU 時間和 Cloudflare 上的不同,實際 CPU 時間仍要從 Workers Logs 或 `wrangler tail` 觀察。超標的請求會回 Error 1102,在儀表板 Metrics > Errors > Invocation Statuses 記為 Exceeded CPU Time Limits(outcome 為 `exceededCpu`),來源同上方的 limits 頁。
Loading