From 4421c09224201c44d0394d98dc25d961bbf08856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Fri, 24 Jul 2026 16:29:23 +0100 Subject: [PATCH] Add bunny.net edge script for geo-localized pricing + deploy workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the bunny.net middleware edge script that rewrites the displayed price based on the visitor's country (CDN-RequestCountryCode), plus the manual workflow to deploy it. This is the deployable half of geo-localized pricing; the website changes (data-price markers) arrive separately. Edge script (edge-script/): - Middleware using servePullZone + HTMLRewriter. Non-UK visitors get $35/year; UK/unknown visitors see the static default (£25/year) baked into the HTML. Safe degradation if the script is disabled or the header is missing. - Bundled to a single file via esbuild + @luca/esbuild-deno-loader (mirrors the official BunnyWay template). Deploy: - deploy-edge-script.yml: manual-dispatch workflow. Type-checks, bundles, and uploads the edge script to a bunny edge script id. Secrets: BUNNY_STAGING_SCRIPT_ID / BUNNY_STAGING_DEPLOY_KEY. The script is generic and safe to merge independently: it only rewrites elements with data-price="" attributes, which don't exist on the site yet (those arrive with the website changes). Until then it's a no-op on every page, so merging this first carries no risk. --- .github/workflows/deploy-edge-script.yml | 64 +++++++ edge-script/.gitignore | 3 + edge-script/README.md | 115 +++++++++++++ edge-script/build.mjs | 16 ++ edge-script/deno.json | 13 ++ edge-script/deno.lock | 204 +++++++++++++++++++++++ edge-script/src/bunny-globals.d.ts | 42 +++++ edge-script/src/main.ts | 3 + edge-script/src/pricing.ts | 57 +++++++ 9 files changed, 517 insertions(+) create mode 100644 .github/workflows/deploy-edge-script.yml create mode 100644 edge-script/.gitignore create mode 100644 edge-script/README.md create mode 100644 edge-script/build.mjs create mode 100644 edge-script/deno.json create mode 100644 edge-script/deno.lock create mode 100644 edge-script/src/bunny-globals.d.ts create mode 100644 edge-script/src/main.ts create mode 100644 edge-script/src/pricing.ts diff --git a/.github/workflows/deploy-edge-script.yml b/.github/workflows/deploy-edge-script.yml new file mode 100644 index 0000000..5c4e804 --- /dev/null +++ b/.github/workflows/deploy-edge-script.yml @@ -0,0 +1,64 @@ +name: Deploy edge script (staging) + +# Deploys the bunny.net middleware edge script (edge-script/) that rewrites the +# displayed price based on the visitor's country (CDN-RequestCountryCode). +# +# Manual only — run from the Actions tab. Use this to test the edge script on +# a staging pull zone before wiring it to production. +# +# Required repo secrets (see edge-script/README.md for how to get them): +# BUNNY_STAGING_SCRIPT_ID - the edge script id (from bunny dashboard) +# BUNNY_STAGING_DEPLOY_KEY - the script's deploy key +# +# Optional inputs: +# script-id - override BUNNY_STAGING_SCRIPT_ID for a one-off deploy target +# file - path to the bundled script (default edge-script/dist/index.ts) + +on: + workflow_dispatch: + inputs: + script-id: + description: "Edge script id (overrides BUNNY_STAGING_SCRIPT_ID secret)" + required: false + type: string + file: + description: "Bundled script path to deploy" + required: false + type: string + default: "edge-script/dist/index.ts" + +# Prevent two manual runs from racing to upload the same script. +concurrency: + group: deploy-edge-script-staging + cancel-in-progress: true + +permissions: {} + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 + with: + deno-version: v2.x + + - name: Type-check + working-directory: edge-script + run: deno check src/main.ts + + - name: Bundle + working-directory: edge-script + run: deno task build + + - name: Deploy script to Bunny Edge Scripting + uses: BunnyWay/actions/deploy-script@cfed0ba7842c85686d0dc3a83d8d971991a752f6 # deploy-script@0.5.0 + with: + script_id: ${{ inputs.script-id || secrets.BUNNY_STAGING_SCRIPT_ID }} + deploy_key: ${{ secrets.BUNNY_STAGING_DEPLOY_KEY }} + file: ${{ inputs.file }} diff --git a/edge-script/.gitignore b/edge-script/.gitignore new file mode 100644 index 0000000..1e89d84 --- /dev/null +++ b/edge-script/.gitignore @@ -0,0 +1,3 @@ +# Bundled output (regenerated by `deno task build`, deployed by the +# deploy-edge-script workflow). Keep out of git. +dist/ diff --git a/edge-script/README.md b/edge-script/README.md new file mode 100644 index 0000000..deb8f8f --- /dev/null +++ b/edge-script/README.md @@ -0,0 +1,115 @@ +# Edge script: geo-localized pricing + +A bunny.net **middleware** edge script that rewrites the displayed price on the +pricing page based on the visitor's country, using bunny's `CDN-RequestCountryCode` +header and `HTMLRewriter` (streaming, no buffering). + +| Visitor | Price shown | +| --------------- | ------------- | +| UK (GB, GG, JE, IM) | £25/year | +| Everywhere else | $35/year | + +## How it works + +The static site (`/pricing/`) ships with the **UK price as the default** +(`£25/year`). The price is marked in HTML with +`data-price=""` attributes (on the hero heading and the price table cell), and +the Pricing Vue component is rendered with **no `client:load`** — so the price +is plain static HTML with no hydration that could snap it back. + +On every request, bunny injects `CDN-RequestCountryCode` (ISO-3166-1 alpha-2). +The middleware: + +1. Reads the country on `onOriginResponse`. +2. For **non-UK** visitors, runs the response through `HTMLRewriter`, replacing + the inner content of every `[data-price]` element with `$35/year`. +3. For **UK** visitors (or when the header is missing), passes the response + through unchanged — they already see £25/year in the static HTML. + +This means the script degrades safely: if the script is disabled or the header +is absent, visitors see the UK default. + +## Files + +- `src/main.ts` — entry point (imports `pricing.ts`). +- `src/pricing.ts` — the middleware. +- `src/bunny-globals.d.ts` — ambient types for bunny runtime globals + (`HTMLRewriter`) not shipped with the SDK. +- `build.mjs` — esbuild + `@luca/esbuild-deno-loader` bundler, inlines the + `https://esm.sh/...` SDK import into a single `dist/index.ts`. +- `deno.json` — Deno tasks (`build`, `check`, `dev`). + +## Local development + +```bash +cd edge-script + +# Type-check +deno check src/main.ts + +# Bundle to dist/index.ts +deno task build + +# Run locally (proxies to https://shroud.email/ as the origin) +deno task dev +``` + +Then test with curl (simulating a non-UK visitor — the default origin HTML +already has £25, so the script rewrites to $35): + +```bash +curl http://127.0.0.1:8080/pricing/ | grep data-price +``` + +## Deploy (staging) + +Deployment is via a **manual** GitHub workflow: +`.github/workflows/deploy-edge-script.yml` (run it from the Actions tab). + +### One-time setup in bunny + +1. In the bunny dashboard, create an **Edge Script** of type **Middleware**. +2. Attach it to your **staging Pull Zone** (the one fronting the static site). +3. Under **Script → Deployments → Settings**, copy the **Script ID** and + **Deploy Key**. + +### One-time setup in GitHub + +Add two repository secrets (Settings → Secrets and variables → Actions): + +| Secret name | Value | +| ---------------------------- | ------------------------------ | +| `BUNNY_STAGING_SCRIPT_ID` | The edge script id | +| `BUNNY_STAGING_DEPLOY_KEY` | The script's deploy key | + +### Deploy + +Run the **"Deploy edge script (staging)"** workflow from the Actions tab. It +type-checks, bundles, and uploads `edge-script/dist/index.ts` to bunny. + +### Verify + +With the script attached to the staging pull zone, check the rewritten price: + +```bash +# Non-UK (e.g. US) — bunny routes through a non-UK PoP, expect $35/year +curl -s https://staging.shroud.email/pricing/ | grep -o 'data-price="">[^<]*' + +# Force a UK egress isn't trivial from curl; verify from a UK network/VPN, +# or check the bunny dashboard → Script → Logs. +``` + +## Production + +Once validated on staging: + +1. Create a production Edge Script + attach to the production Pull Zone. +2. Add `BUNNY_SCRIPT_ID` / `BUNNY_DEPLOY_KEY` secrets and copy this workflow + to `deploy-edge-script-prod.yml` (or extend the existing one with an + environment selector). + +## Changing the prices + +Edit `WORLDWIDE_PRICE` and `UK_COUNTRY_CODES` in `src/pricing.ts`, **and** the +default price baked into `src/components/organisms/Pricing.vue` (the UK price +must stay the static default so the no-script fallback stays correct). diff --git a/edge-script/build.mjs b/edge-script/build.mjs new file mode 100644 index 0000000..6ed8226 --- /dev/null +++ b/edge-script/build.mjs @@ -0,0 +1,16 @@ +// Bundles the edge script into a single file (dist/index.ts) that the bunny.net +// deploy action uploads. Mirrors the official BunnyWay/es-empty-script setup: +// esbuild + @luca/esbuild-deno-loader resolves the `https://esm.sh/...` import +// at build time and inlines it. +import * as esbuild from "npm:esbuild"; +import { denoPlugins } from "jsr:@luca/esbuild-deno-loader"; + +await esbuild.build({ + plugins: [...denoPlugins()], + entryPoints: ["./src/main.ts"], + outfile: "./dist/index.ts", + bundle: true, + format: "esm", +}); + +esbuild.stop(); diff --git a/edge-script/deno.json b/edge-script/deno.json new file mode 100644 index 0000000..c09463f --- /dev/null +++ b/edge-script/deno.json @@ -0,0 +1,13 @@ +{ + // Used by `deno task build` to bundle the edge script. + "tasks": { + "build": "mkdir -p dist && deno run -A build.mjs", + "check": "deno check src/main.ts", + "dev": "deno run --allow-net --allow-read src/main.ts" + }, + "lint": { + "rules": { + "tags": ["recommended"] + } + } +} diff --git a/edge-script/deno.lock b/edge-script/deno.lock new file mode 100644 index 0000000..89730af --- /dev/null +++ b/edge-script/deno.lock @@ -0,0 +1,204 @@ +{ + "version": "5", + "specifiers": { + "jsr:@luca/esbuild-deno-loader@*": "0.11.1", + "jsr:@std/bytes@^1.0.2": "1.0.6", + "jsr:@std/encoding@^1.0.5": "1.0.11", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.0.6": "1.1.6", + "npm:esbuild@*": "0.28.1" + }, + "jsr": { + "@luca/esbuild-deno-loader@0.11.1": { + "integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267", + "dependencies": [ + "jsr:@std/bytes", + "jsr:@std/encoding", + "jsr:@std/path" + ] + }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, + "@std/encoding@1.0.11": { + "integrity": "e7cef2f0b3153bccc17431e7c864a1de03a4b6d9647389c43e08aaa775d37385" + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal" + ] + } + }, + "npm": { + "@esbuild/aix-ppc64@0.28.1": { + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.28.1": { + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.28.1": { + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.28.1": { + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.28.1": { + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.28.1": { + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.28.1": { + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.28.1": { + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.28.1": { + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.28.1": { + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.28.1": { + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.28.1": { + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.28.1": { + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.28.1": { + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.28.1": { + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.28.1": { + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.28.1": { + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.28.1": { + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.28.1": { + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.28.1": { + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.28.1": { + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.28.1": { + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.28.1": { + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.28.1": { + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.28.1": { + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.28.1": { + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "os": ["win32"], + "cpu": ["x64"] + }, + "esbuild@0.28.1": { + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "optionalDependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/openharmony-arm64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ], + "scripts": true, + "bin": true + } + }, + "remote": { + "https://esm.sh/@bunny.net/edgescript-sdk@0.12.0": "2b6ef65794a748f842dfe40d7ad9414ca3c2670efc1c396a0fc22ec6c8e74318" + } +} diff --git a/edge-script/src/bunny-globals.d.ts b/edge-script/src/bunny-globals.d.ts new file mode 100644 index 0000000..855329a --- /dev/null +++ b/edge-script/src/bunny-globals.d.ts @@ -0,0 +1,42 @@ +// Ambient types for the bunny.net edge runtime globals that aren't shipped in +// the @bunny.net/edgescript-sdk type definitions (HTMLRewriter + its Element +// type). These exist at runtime on bunny's edge; declaring them here lets +// `deno check` pass. Only the slice of the API we use is typed. +// Ref: https://docs.bunny.net/scripting/html-rewriter + +declare global { + /** Options accepted by content-mutation methods on {@link Element}. */ + interface ContentOptions { + html?: boolean; + } + + /** An HTML element matched by an {@link HTMLRewriter} selector. */ + interface HtmlRewriterElement { + tagName: string; + getAttribute(name: string): string | null; + hasAttribute(name: string): boolean; + setAttribute(name: string, value: string): HtmlRewriterElement; + removeAttribute(name: string): HtmlRewriterElement; + setInnerContent(content: string, options?: ContentOptions): HtmlRewriterElement; + before(content: string, options?: ContentOptions): HtmlRewriterElement; + after(content: string, options?: ContentOptions): HtmlRewriterElement; + remove(): HtmlRewriterElement; + } + + /** Handler invoked for each element matching a selector. */ + interface ElementHandler { + element?(el: HtmlRewriterElement): void | Promise; + } + + /** + * Streaming HTML rewriter. Transforms a {@link Response} body in place, + * calling handlers as matching elements stream through. Returns a new + * Response (same headers, minus Content-Length) with the rewritten body. + */ + class HTMLRewriter { + on(selector: string, handlers: ElementHandler): this; + transform(response: Response): Response; + } +} + +export {}; diff --git a/edge-script/src/main.ts b/edge-script/src/main.ts new file mode 100644 index 0000000..8ac3a61 --- /dev/null +++ b/edge-script/src/main.ts @@ -0,0 +1,3 @@ +// Entry point for the bunny.net edge script. Re-exports the middleware so the +// SDK's top-level `servePullZone()` call runs when the bundle is loaded. +import "./pricing.ts"; diff --git a/edge-script/src/pricing.ts b/edge-script/src/pricing.ts new file mode 100644 index 0000000..6c07af7 --- /dev/null +++ b/edge-script/src/pricing.ts @@ -0,0 +1,57 @@ +// Bunny.net middleware edge script: geo-localized pricing. +// +// bunny.net injects `CDN-RequestCountryCode` (ISO-3166-1 alpha-2) on every +// request. We read it in onOriginResponse and rewrite the price inside any +// element marked with `data-price=""` to the visitor's localized price, using +// HTMLRewriter (streaming, no buffering). +// +// The static HTML ships with the UK price as the default, so if the script is +// ever disabled or the country header is missing, visitors see £25/year. +// +// Elements to rewrite are produced by the Pricing component, which tags the +// hero heading and the price table cell with `data-price=""`. + +import * as BunnySDK from "https://esm.sh/@bunny.net/edgescript-sdk@0.12.0"; +import "./bunny-globals.d.ts"; + +// Price shown to visitors outside the UK. The UK price (£25/year) is the +// default already baked into the static HTML. +const WORLDWIDE_PRICE = "$35/year"; + +// Country codes that should see the UK price. GB + the Crown dependencies +// (Guernsey, Jersey, Isle of Man) share the UK billing entity in Paddle. +const UK_COUNTRY_CODES = new Set(["GB", "GG", "JE", "IM"]); + +function isUK(country: string | null): boolean { + return country !== null && UK_COUNTRY_CODES.has(country.toUpperCase()); +} + +// The `url` is only used for local development; in production bunny proxies +// to the origin configured on the Pull Zone. Point it at the live site so +// `deno task dev` fetches real HTML when testing locally. +const ORIGIN_URL = "https://shroud.email/"; + +BunnySDK.net.http + .servePullZone({ url: ORIGIN_URL }) + .onOriginResponse((ctx) => { + // Only rewrite HTML responses. + const type = ctx.response.headers.get("content-type") ?? ""; + if (!type.includes("text/html")) { + return Promise.resolve(ctx.response); + } + + const country = ctx.request.headers.get("cdn-requestcountrycode"); + if (isUK(country)) { + // UK visitor: the static default is already £25/year, nothing to do. + return Promise.resolve(ctx.response); + } + + // Worldwide visitor: replace the default £25/year with $35/year. + const rewriter = new HTMLRewriter().on("[data-price]", { + element(el: HtmlRewriterElement) { + el.setInnerContent(WORLDWIDE_PRICE); + }, + }); + + return Promise.resolve(rewriter.transform(ctx.response)); + });