feat(website): enable OpenNext skew protection + tune CF cache (#20760)

## 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
(`<old-version>-twenty-website-<env>.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=<previous-deploy-id>`) |
`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-<env> }]` — 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.
This commit is contained in:
Félix Malfait
2026-05-20 12:47:12 +02:00
committed by GitHub
parent c800eccc65
commit 00fad657f4
4 changed files with 70 additions and 4 deletions
+7
View File
@@ -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: [
+16 -1
View File
@@ -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,
},
},
};
-2
View File
@@ -1,2 +0,0 @@
/_next/static/*
Cache-Control: public, max-age=31536000, immutable
+47 -1
View File
@@ -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 `<version>-<worker>.<account>.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: `<version>-<worker>.<account>.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
// `<old-version-id>-<CF_WORKER_NAME>.<CF_PREVIEW_DOMAIN>.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",
},
},
},
}