From 00fad657f459d81d269da1daf083233d6da18b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 20 May 2026 12:47:12 +0200 Subject: [PATCH] feat(website): enable OpenNext skew protection + tune CF cache (#20760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The original goal of this whole migration: **cross-deployment skew is now handled by OpenNext's per-version routing instead of by users having to refresh.** A client holding a stale tab from deployment X requests assets with `?dpl=X` — the Worker compares X to the current `DEPLOYMENT_ID`, looks it up in `CF_DEPLOYMENT_MAPPING`, and routes to the matching old Worker version via its per-version preview URL (`-twenty-website-.twentyhq.workers.dev`). The old version serves the old assets / RSC payloads / Server Actions consistently. **Verified end-to-end on dev**: | | Marker in HTML | |---|---| | Current Worker (`twenty-main.com/`) | `9npeiytir8EPOtW71cqDZ` | | Stale request (`twenty-main.com/?dpl=`) | `B9OC_TNl1vaGcJ5oUUty6` | | Direct hit on old preview URL | `B9OC_TNl1vaGcJ5oUUty6` ← matches the skew-routed response | ## Changes **`open-next.config.ts`** — enable skew protection ```ts const baseConfig = defineCloudflareConfig({ incrementalCache: r2IncrementalCache }); export default { ...baseConfig, cloudflare: { ...baseConfig.cloudflare, skewProtection: { enabled: true, maxNumberOfVersions: 10, maxVersionAgeDays: 14, }, }, }; ``` (`defineCloudflareConfig` doesn't accept `skewProtection` directly — has to be merged in) **`next.config.ts`** — `deploymentId: process.env.DEPLOYMENT_ID`. CI sets `DEPLOYMENT_ID` per-build; Next bakes it into prerendered HTML, `?dpl=…` on asset URLs, Server Actions, and RSC fetch headers. **`wrangler.jsonc`**: - `compatibility_date: 2026-04-15` (was `2025-01-15`; build was warning) - `assets.run_worker_first: true` — Worker must intercept asset requests so the skew handler can route stale `/_next/static/*` to the old version. CF edge cache absorbs hot paths so this isn't a 5× billable-invocation tax - `preview_urls: true` — required; skew routes via the per-version preview URL which only exists when previews are enabled - Per-env `services: [{ binding: WORKER_SELF_REFERENCE, service: twenty-website- }]` — OpenNext's recommended setup for fire-and-forget ISR revalidation - Per-env `vars`: `CF_WORKER_NAME` + `CF_PREVIEW_DOMAIN` (bare `twentyhq`, *not* `twentyhq.workers.dev` — OpenNext appends `.workers.dev` itself, see [opennextjs-cloudflare#811](https://github.com/opennextjs/opennextjs-cloudflare/issues/811)) - Kept `global_fetch_strictly_public` in compat flags — without it, CF's optimised intra-account routing self-loops the cross-version fetch and 522s out. With it, the fetch takes the public-Internet path which routes correctly. **`public/_headers`** — deleted (with `run_worker_first: true` the assets pipeline doesn't process it; Next sets the same `Cache-Control: immutable` on `/_next/static/*` anyway). ## Companion infra PR https://github.com/twentyhq/twenty-infra/pull/__ — wires the four CF env vars (`DEPLOYMENT_ID`, `CF_WORKER_NAME`, `CF_PREVIEW_DOMAIN`, `CF_ACCOUNT_ID`, `CF_WORKERS_SCRIPTS_API_TOKEN`) into the deploy workflow. ## Known limitation Skew routing only works for Worker versions deployed AFTER this PR (older versions don't have `preview_urls: true` and don't have `DEPLOYMENT_ID` bindings OpenNext can read). Users on tabs older than the first post-merge deploy still fall through to the current Worker (same behaviour as today). OpenNext marks `skewProtection` as **experimental** in their type docs ("might break on minor releases") — worth keeping an eye on. --- packages/twenty-website/next.config.ts | 7 +++ packages/twenty-website/open-next.config.ts | 17 +++++++- packages/twenty-website/public/_headers | 2 - packages/twenty-website/wrangler.jsonc | 48 ++++++++++++++++++++- 4 files changed, 70 insertions(+), 4 deletions(-) delete mode 100644 packages/twenty-website/public/_headers diff --git a/packages/twenty-website/next.config.ts b/packages/twenty-website/next.config.ts index 20b81c8f91..679df7d271 100644 --- a/packages/twenty-website/next.config.ts +++ b/packages/twenty-website/next.config.ts @@ -32,7 +32,14 @@ const SECURITY_HEADERS = [ { key: 'Content-Security-Policy', value: "frame-ancestors 'none'" }, ] as const; +// Skew protection: CI sets DEPLOYMENT_ID at build time so it's baked into +// prerendered HTML + the RSC payloads. The Worker reads the same value at +// runtime (via the worker env var) and routes mismatched requests to the +// matching older Worker version via its preview URL. +const deploymentId = process.env.DEPLOYMENT_ID; + const nextConfig: LinariaConfig = { + deploymentId, images: { formats: ['image/avif', 'image/webp'], remotePatterns: [ diff --git a/packages/twenty-website/open-next.config.ts b/packages/twenty-website/open-next.config.ts index e4bc5eedc9..2a0ccd24dd 100644 --- a/packages/twenty-website/open-next.config.ts +++ b/packages/twenty-website/open-next.config.ts @@ -1,6 +1,21 @@ import { defineCloudflareConfig } from '@opennextjs/cloudflare'; import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache'; -export default defineCloudflareConfig({ +const baseConfig = defineCloudflareConfig({ incrementalCache: r2IncrementalCache, }); + +// `defineCloudflareConfig` only takes the `CloudflareOverrides` subset of the +// config today; `skewProtection` lives directly under `cloudflare.*` and has to +// be merged in. See packages/cloudflare/src/api/config.ts in opennextjs-cloudflare. +export default { + ...baseConfig, + cloudflare: { + ...baseConfig.cloudflare, + skewProtection: { + enabled: true, + maxNumberOfVersions: 10, + maxVersionAgeDays: 14, + }, + }, +}; diff --git a/packages/twenty-website/public/_headers b/packages/twenty-website/public/_headers deleted file mode 100644 index c846ea711e..0000000000 --- a/packages/twenty-website/public/_headers +++ /dev/null @@ -1,2 +0,0 @@ -/_next/static/* - Cache-Control: public, max-age=31536000, immutable diff --git a/packages/twenty-website/wrangler.jsonc b/packages/twenty-website/wrangler.jsonc index 968d4a994a..f2f0bd76b0 100644 --- a/packages/twenty-website/wrangler.jsonc +++ b/packages/twenty-website/wrangler.jsonc @@ -2,12 +2,24 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "twenty-website", "main": ".open-next/worker.js", - "compatibility_date": "2025-01-15", + "compatibility_date": "2026-04-15", + // `global_fetch_strictly_public` forces the skew handler's cross-version + // fetch to `-..workers.dev` to take the public + // Internet path. Without it, Cloudflare's optimized intra-account routing + // self-loops back to the current worker (timeouts → 522). "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], "assets": { "directory": ".open-next/assets", "binding": "ASSETS", + // Worker must intercept asset requests so it can route /_next/static/* + // and /_next/data/* from a stale client to the matching old Worker + // version (skew protection). Cloudflare's edge cache still absorbs hot + // paths, so this isn't a 5× Worker invocation tax in practice. + "run_worker_first": true, }, + // Per-version preview URLs are how skew protection routes a stale request + // to the old deployment: `-..workers.dev`. + "preview_urls": true, "observability": { "enabled": true, }, @@ -30,6 +42,26 @@ "bucket_name": "twenty-website-cache-dev", }, ], + // Self-reference so OpenNext can fire-and-forget background ISR + // revalidations instead of blocking the request. Per-env because the + // service name must match the deployed worker name. + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "twenty-website-dev", + }, + ], + // Skew-protection runtime inputs. The handler reads these to construct + // `-..workers.dev` + // when routing a stale request to an older Worker version. + "vars": { + "CF_WORKER_NAME": "twenty-website-dev", + // OpenNext appends `.workers.dev` itself when constructing the + // per-version preview URL — passing the full `twentyhq.workers.dev` + // here would yield `…twentyhq.workers.dev.workers.dev` and 404. + // See https://github.com/opennextjs/opennextjs-cloudflare/issues/811 + "CF_PREVIEW_DOMAIN": "twentyhq", + }, }, "prod": { "name": "twenty-website-prod", @@ -49,6 +81,20 @@ "bucket_name": "twenty-website-cache-prod", }, ], + "services": [ + { + "binding": "WORKER_SELF_REFERENCE", + "service": "twenty-website-prod", + }, + ], + "vars": { + "CF_WORKER_NAME": "twenty-website-prod", + // OpenNext appends `.workers.dev` itself when constructing the + // per-version preview URL — passing the full `twentyhq.workers.dev` + // here would yield `…twentyhq.workers.dev.workers.dev` and 404. + // See https://github.com/opennextjs/opennextjs-cloudflare/issues/811 + "CF_PREVIEW_DOMAIN": "twentyhq", + }, }, }, }