diff --git a/.coderabbit.yaml b/.coderabbit.yaml index c588117..996b89a 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -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" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8713149..ed79dfb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index d805bb5..20d36fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 04e935e..ba890a4 100644 --- a/README.md +++ b/README.md @@ -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..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: @@ -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. @@ -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). diff --git a/README.zh-TW.md b/README.zh-TW.md index 8971f67..ea13a59 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -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 後台綁自訂網域。 手動部署: @@ -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 以上。 @@ -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)。 diff --git a/cloudflare.config.ts b/cloudflare.config.ts index abf1034..e9dbd2b 100644 --- a/cloudflare.config.ts +++ b/cloudflare.config.ts @@ -1,4 +1,6 @@ -import { defineConfig } from "cf/config"; +import { bindings, defineConfig } from "cf/config"; + +const buildEnv = (globalThis as { process?: { env?: Record } }).process?.env; export default defineConfig({ worker: { @@ -8,5 +10,6 @@ export default defineConfig({ observability: { enabled: true, }, + ...(buildEnv?.LOG_SHARED_LINKS === "1" ? { env: { SHARED_LINKS: bindings.kv() } } : {}), }, }); diff --git a/docs/spec/fail-open-to-original.md b/docs/spec/fail-open-to-original.md index f3e29d3..86e309a 100644 --- a/docs/spec/fail-open-to-original.md +++ b/docs/spec/fail-open-to-original.md @@ -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 --- diff --git a/docs/spec/free-plan-bindings-only.md b/docs/spec/free-plan-bindings-only.md index b86a4c9..00b57e1 100644 --- a/docs/spec/free-plan-bindings-only.md +++ b/docs/spec/free-plan-bindings-only.md @@ -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 只存在寫入它的資料中心、不會同步到其他地方,這是接受的代價,不是要修的問題。 diff --git a/docs/spec/shared-links-logged-to-kv-at-conversion.md b/docs/spec/shared-links-logged-to-kv-at-conversion.md new file mode 100644 index 0000000..86ed68a --- /dev/null +++ b/docs/spec/shared-links-logged-to-kv-at-conversion.md @@ -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` 永不結束時回應也不延遲;分享連結轉址與失敗的轉換都不寫入。 diff --git a/docs/spec/tracking-params-cleaned-at-conversion.md b/docs/spec/tracking-params-cleaned-at-conversion.md index f3ab829..dc79702 100644 --- a/docs/spec/tracking-params-cleaned-at-conversion.md +++ b/docs/spec/tracking-params-cleaned-at-conversion.md @@ -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 --- diff --git a/src/index.ts b/src/index.ts index b2dcbf0..d66c080 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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?))?\/?$/; @@ -40,7 +41,7 @@ function text(status: number, body: string): Response { }); } -async function convert(requestUrl: URL): Promise { +async function convert(requestUrl: URL, env: Env, ctx: ExecutionContext): Promise { const raw = requestUrl.searchParams.get("url"); if (raw === null || raw === "") return text(400, CONVERT_HINT); let target: URL; @@ -51,8 +52,11 @@ async function convert(requestUrl: URL): Promise { } 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( @@ -198,9 +202,9 @@ export function createWorker(deps: { cache: () => PostCache }): { fetch(request: Request, env: Env, ctx: ExecutionContext): Promise; } { 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); }, diff --git a/src/shared-link-log.ts b/src/shared-link-log.ts new file mode 100644 index 0000000..8e6a728 --- /dev/null +++ b/src/shared-link-log.ts @@ -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; +}; + +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. + } +} diff --git a/test/shared-links-logged-to-kv-at-conversion.test.ts b/test/shared-links-logged-to-kv-at-conversion.test.ts new file mode 100644 index 0000000..855ddd4 --- /dev/null +++ b/test/shared-links-logged-to-kv-at-conversion.test.ts @@ -0,0 +1,384 @@ +import { afterEach, expect, setSystemTime, spyOn, test } from "bun:test"; +import { createWorker } from "../src/index"; +import { logSharedLink } from "../src/shared-link-log"; +import { fakeCache } from "./support/fake-cache"; +import { fakeKv } from "./support/fake-kv"; + +const KEY = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z-[0-9a-f-]{36}$/; +const YOUTUBE = "https://www.youtube.com/watch?v=abc"; + +afterEach(() => setSystemTime()); + +function fakeCtx() { + const pending: Promise[] = []; + const ctx = { + waitUntil(this: unknown, promise: Promise) { + if (this !== ctx) throw new TypeError("waitUntil must be called as a method"); + pending.push(promise); + }, + }; + return { ctx: ctx as unknown as ExecutionContext, pending }; +} + +function envWith(binding: unknown): Env { + return { SHARED_LINKS: binding } as unknown as Env; +} + +test("one conversion writes one expiring, time-keyed record", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); + expect(pending).toHaveLength(1); + await Promise.all(pending); + expect(kv.puts).toHaveLength(1); + const [put] = kv.puts; + expect(put?.key).toMatch(KEY); + const at = put?.key.slice(0, 24); + expect(put?.options.expirationTtl).toBe(7_776_000); + expect(put?.value).toBe(YOUTUBE); + expect(put?.options.metadata).toEqual({ url: YOUTUBE, at }); +}); + +test("raw conversions record raw: true in metadata", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, true); + await Promise.all(pending); + const [put] = kv.puts; + expect(put?.options.metadata).toEqual({ url: YOUTUBE, at: put?.key.slice(0, 24), raw: true }); +}); + +test("keys from later calls sort after keys from earlier calls", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); + setSystemTime(new Date("2026-06-01T00:00:00.000Z")); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); + await Promise.all(pending); + const keys = kv.puts.map((put) => put.key); + expect(keys).toEqual([...keys].sort()); +}); + +test("calls under one frozen time share the timestamp prefix and differ in key", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + setSystemTime(new Date("2026-03-03T03:03:03.003Z")); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); + await Promise.all(pending); + const [first, second] = kv.puts; + expect(first?.key).not.toBe(second?.key); + expect(first?.key.slice(0, 24)).toBe("2026-03-03T03:03:03.003Z"); + expect(second?.key.slice(0, 24)).toBe("2026-03-03T03:03:03.003Z"); +}); + +test("waitUntil is invoked as a method on ctx", () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + expect(() => logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false)).not.toThrow(); + expect(pending).toHaveLength(1); +}); + +test("the collected promise is the write itself", async () => { + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); + expect(kv.puts).toHaveLength(1); + await Promise.all(pending); + expect(kv.puts).toHaveLength(1); +}); + +test("without the binding nothing is scheduled", () => { + for (const env of [{}, { SHARED_LINKS: undefined }]) { + const { ctx, pending } = fakeCtx(); + expect(logSharedLink(env as unknown as Env, ctx, YOUTUBE, false)).toBeUndefined(); + expect(pending).toHaveLength(0); + } +}); + +test("a put that throws synchronously is swallowed", async () => { + const kv = fakeKv("throw"); + const { ctx, pending } = fakeCtx(); + expect(() => logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false)).not.toThrow(); + await Promise.all(pending); + expect(kv.puts).toHaveLength(1); +}); + +test("a put that rejects is swallowed without an unhandled rejection", async () => { + const kv = fakeKv("reject"); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); + await expect(Promise.all(pending)).resolves.toBeDefined(); +}); + +test("a binding without put is swallowed", async () => { + const { ctx, pending } = fakeCtx(); + expect(() => logSharedLink(envWith({}), ctx, YOUTUBE, false)).not.toThrow(); + await expect(Promise.all(pending)).resolves.toBeDefined(); +}); + +test("a waitUntil that throws does not reach the caller", () => { + const kv = fakeKv(); + const ctx = { + waitUntil() { + throw new Error("boom"); + }, + } as unknown as ExecutionContext; + expect(() => logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false)).not.toThrow(); +}); + +test("a failed write logs nothing to the console", async () => { + const spies = [spyOn(console, "log"), spyOn(console, "error"), spyOn(console, "warn")]; + try { + const kv = fakeKv("reject"); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, YOUTUBE, false); + await Promise.all(pending); + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + } finally { + for (const spy of spies) spy.mockRestore(); + } +}); + +function sizeOf(metadata: object): number { + return new TextEncoder().encode(JSON.stringify(metadata)).byteLength; +} + +function urlWithMetadataBytes(target: number): string { + const at = "2026-04-04T04:04:04.004Z"; + const base = sizeOf({ url: "https://example.com/", at }); + return `https://example.com/${"a".repeat(target - base)}`; +} + +test("metadata keeps the url when it is exactly 1,024 bytes", async () => { + setSystemTime(new Date("2026-04-04T04:04:04.004Z")); + const url = urlWithMetadataBytes(1_024); + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, url, false); + await Promise.all(pending); + expect(sizeOf(kv.puts[0]?.options.metadata ?? {})).toBe(1_024); + expect(kv.puts[0]?.options.metadata).toHaveProperty("url", url); +}); + +test("metadata drops the url at 1,025 bytes but still logs it as the value", async () => { + setSystemTime(new Date("2026-04-04T04:04:04.004Z")); + const url = urlWithMetadataBytes(1_025); + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, url, false); + await Promise.all(pending); + expect(kv.puts[0]?.options.metadata).toEqual({ at: "2026-04-04T04:04:04.004Z" }); + expect(kv.puts[0]?.value).toBe(url); +}); + +test("the size limit counts bytes, not characters", async () => { + // 500 "é" are 1,000 bytes but only 500 characters. + const url = `https://example.com/${"é".repeat(500)}`; + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, url, false); + await Promise.all(pending); + expect(kv.puts[0]?.options.metadata).toEqual({ at: expect.any(String) }); +}); + +test("an oversize raw record keeps raw: true without the url", async () => { + setSystemTime(new Date("2026-04-04T04:04:04.004Z")); + const kv = fakeKv(); + const { ctx, pending } = fakeCtx(); + logSharedLink(envWith(kv.binding), ctx, urlWithMetadataBytes(1_025), true); + await Promise.all(pending); + expect(kv.puts[0]?.options.metadata).toEqual({ at: "2026-04-04T04:04:04.004Z", raw: true }); +}); + +const YOUTUBE_REQUEST = "https://bl.example/?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Dabc"; + +function appWithCache() { + return createWorker({ cache: () => fakeCache().cache }); +} + +function runConversion(env: Env, requestUrl = YOUTUBE_REQUEST) { + const { ctx, pending } = fakeCtx(); + const app = appWithCache(); + return { response: app.fetch(new Request(requestUrl), env, ctx), pending }; +} + +async function snapshot(res: Response) { + return { status: res.status, body: await res.text(), type: res.headers.get("content-type") }; +} + +test("a successful conversion writes one record and returns the same response", async () => { + const withKv = fakeKv(); + const { response, pending } = runConversion(envWith(withKv.binding)); + const body = await snapshot(await response); + await Promise.all(pending); + + const plain = await snapshot(await runConversion({} as Env).response); + expect(body).toEqual(plain); + expect(body.body).toBe("https://bl.example/www.youtube.com/watch?v=abc"); + expect(withKv.puts).toHaveLength(1); + expect(withKv.puts[0]?.options.metadata).toEqual({ url: YOUTUBE, at: withKv.puts[0]?.key.slice(0, 24) }); + expect(withKv.puts[0]?.options.expirationTtl).toBe(7_776_000); +}); + +test("raw=1 writes one record marked raw and returns the same response", async () => { + const kv = fakeKv(); + const { response, pending } = runConversion(envWith(kv.binding), `${YOUTUBE_REQUEST}&raw=1`); + const body = await snapshot(await response); + await Promise.all(pending); + expect(body.body).toBe(YOUTUBE); + expect(kv.puts).toHaveLength(1); + expect(kv.puts[0]?.options.metadata).toHaveProperty("raw", true); +}); + +test("without the binding the response is unchanged and nothing is scheduled", async () => { + const { response, pending } = runConversion({} as Env); + expect((await response).status).toBe(200); + expect(pending).toHaveLength(0); +}); + +test("a throwing or rejecting put does not change the response", async () => { + const plain = await snapshot(await runConversion({} as Env).response); + for (const mode of ["throw", "reject"] as const) { + const { response, pending } = runConversion(envWith(fakeKv(mode).binding)); + const body = await snapshot(await response); + await Promise.all(pending); + expect(body).toEqual(plain); + } +}); + +test("a put that never settles does not delay the response", async () => { + const { response } = runConversion(envWith(fakeKv("hang").binding)); + const res = await response; + expect(res.status).toBe(200); + expect(await res.text()).toBe("https://bl.example/www.youtube.com/watch?v=abc"); +}); + +test("the recorded url is the cleaned url the response points to", async () => { + const kv = fakeKv(); + const request = "https://bl.example/?url=https%3A%2F%2Fwww.instagram.com%2Fp%2FBsOGulcndj-%2F%3Figsh%3Dxyz"; + const { response, pending } = runConversion(envWith(kv.binding), request); + const body = await (await response).text(); + await Promise.all(pending); + expect(body).toBe("https://bl.example/www.instagram.com/p/BsOGulcndj-/"); + expect(kv.puts[0]?.value).toBe("https://www.instagram.com/p/BsOGulcndj-/"); + expect(kv.puts[0]?.value).not.toContain("igsh"); +}); + +test("the recorded url drops the fragment", async () => { + const kv = fakeKv(); + const request = "https://bl.example/?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Dabc%23t%3D5"; + const { response, pending } = runConversion(envWith(kv.binding), request); + const body = await (await response).text(); + await Promise.all(pending); + expect(body).toBe("https://bl.example/www.youtube.com/watch?v=abc"); + expect(kv.puts[0]?.value).toBe(YOUTUBE); + expect(JSON.stringify(kv.puts[0]?.options.metadata)).not.toContain("#"); +}); + +test("an http input is recorded in https form", async () => { + const kv = fakeKv(); + const request = "https://bl.example/?url=http%3A%2F%2Fexample.com%2Fa%3Fb%3D1&raw=1"; + const { response, pending } = runConversion(envWith(kv.binding), request); + const body = await (await response).text(); + await Promise.all(pending); + expect(body).toBe("https://example.com/a?b=1"); + expect(kv.puts[0]?.value).toBe(body); +}); + +const BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36"; +const DISCORD_UA = "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"; + +function stubFetch(status: number) { + return spyOn(globalThis, "fetch").mockImplementation((() => + Promise.resolve(new Response("", { status }))) as unknown as typeof fetch); +} + +test("share redirects and media never touch kv", async () => { + const kv = fakeKv(); + const env = envWith(kv.binding); + const spy = stubFetch(404); + try { + const cases: [string, string | undefined][] = [ + ["https://bl.example/www.instagram.com/p/BsOGulcndj-/", BROWSER], + ["https://bl.example/www.instagram.com/p/BsOGulcndj-/", DISCORD_UA], + ["https://bl.example/media/BsOGulcndj-/1", undefined], + ]; + for (const [url, ua] of cases) { + const { ctx, pending } = fakeCtx(); + const headers = ua === undefined ? undefined : { "User-Agent": ua }; + const app = appWithCache(); + const plain = await app.fetch(new Request(url, { headers }), {} as Env, fakeCtx().ctx); + const withKv = await app.fetch(new Request(url, { headers }), env, ctx); + expect(withKv.status).toBe(plain.status); + expect(withKv.headers.get("location")).toBe(plain.headers.get("location")); + expect(pending).toHaveLength(0); + } + expect(kv.puts).toHaveLength(0); + } finally { + spy.mockRestore(); + } +}); + +test("failed conversions never touch kv and keep their 400 response", async () => { + const kv = fakeKv(); + const env = envWith(kv.binding); + for (const query of ["", "?url=", "?url=notaurl", "?url=http%3A%2F%2Flocalhost%2Fx"]) { + const { ctx, pending } = fakeCtx(); + const res = await appWithCache().fetch(new Request(`https://bl.example/${query}`), env, ctx); + expect(res.status).toBe(400); + expect(await res.text()).toBe("pass a percent-encoded http(s) URL as ?url="); + expect(pending).toHaveLength(0); + } + expect(kv.puts).toHaveLength(0); +}); + +const CONFIG_FILE = "../cloudflare.config.ts"; +let variant = 0; + +async function configWithBuildVar(value: string | undefined) { + const previous = process.env.LOG_SHARED_LINKS; + try { + if (value === undefined) delete process.env.LOG_SHARED_LINKS; + else process.env.LOG_SHARED_LINKS = value; + variant += 1; + const module = (await import(`${CONFIG_FILE}?variant=${variant}`)) as { default: { worker: { env?: unknown } } }; + return module.default; + } finally { + if (previous === undefined) delete process.env.LOG_SHARED_LINKS; + else process.env.LOG_SHARED_LINKS = previous; + } +} + +test("the default config declares no kv binding", async () => { + const config = await configWithBuildVar(undefined); + expect(JSON.stringify(config)).not.toContain('"type":"kv"'); + expect(config.worker.env).toBeUndefined(); +}); + +test("LOG_SHARED_LINKS=1 declares the SHARED_LINKS kv binding", async () => { + const config = await configWithBuildVar("1"); + expect(JSON.stringify(config.worker.env)).toBe('{"SHARED_LINKS":{"type":"kv"}}'); +}); + +test("any other build value leaves the config without an env", async () => { + for (const value of ["true", "0", "", "11"]) { + const config = await configWithBuildVar(value); + expect(config.worker.env).toBeUndefined(); + } +}); + +test("the test restores LOG_SHARED_LINKS to its value before the test", async () => { + const before = process.env.LOG_SHARED_LINKS; + await configWithBuildVar("1"); + expect(process.env.LOG_SHARED_LINKS).toBe(before); +}); + +test("both READMEs document the opt-in variable and binding", async () => { + for (const name of ["README.md", "README.zh-TW.md"]) { + const text = await Bun.file(new URL(`../${name}`, import.meta.url)).text(); + expect(text).toContain("LOG_SHARED_LINKS"); + expect(text).toContain("SHARED_LINKS"); + } +}); diff --git a/test/support/fake-kv.ts b/test/support/fake-kv.ts new file mode 100644 index 0000000..641b6fa --- /dev/null +++ b/test/support/fake-kv.ts @@ -0,0 +1,24 @@ +export type KvPut = { + key: string; + value: string; + options: { expirationTtl: number; metadata: Record }; +}; + +export type KvMode = "ok" | "throw" | "reject" | "hang"; + +export function fakeKv(mode: KvMode = "ok"): { + binding: { put(key: string, value: string, options: KvPut["options"]): Promise }; + puts: KvPut[]; +} { + const puts: KvPut[] = []; + const binding = { + put(key: string, value: string, options: KvPut["options"]): Promise { + puts.push({ key, value, options }); + if (mode === "throw") throw new Error("boom s3cret"); + if (mode === "reject") return Promise.reject(new Error("boom s3cret")); + if (mode === "hang") return new Promise(() => {}); + return Promise.resolve(); + }, + }; + return { binding, puts }; +}