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:
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/_next/static/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user