00fad657f4
## 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.
263 lines
7.8 KiB
TypeScript
263 lines
7.8 KiB
TypeScript
import path from 'path';
|
|
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
|
|
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
|
|
import { APP_LOCALES } from 'twenty-shared/translations';
|
|
|
|
// Locale URL segments that are actually served (others are normalised away).
|
|
// Mirrors LOCALE_BY_URL_SEGMENT keys in src/lib/i18n/utils/website-locale-segments.ts.
|
|
const DEPLOYED_LOCALE_URL_SEGMENTS = ['en', 'fr'] as const;
|
|
|
|
// Raw locale codes (e.g. fr-FR, de-DE) that should redirect to the un-prefixed
|
|
// path. Excludes pseudo-* locales and the deployed URL segments themselves.
|
|
const RAW_LOCALE_PREFIXES_TO_STRIP = (
|
|
Object.values(APP_LOCALES) as string[]
|
|
).filter(
|
|
(locale) =>
|
|
!locale.startsWith('pseudo-') &&
|
|
!(DEPLOYED_LOCALE_URL_SEGMENTS as readonly string[]).includes(locale),
|
|
);
|
|
|
|
const SECURITY_HEADERS = [
|
|
{
|
|
key: 'Strict-Transport-Security',
|
|
value: 'max-age=63072000; includeSubDomains; preload',
|
|
},
|
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
{
|
|
key: 'Permissions-Policy',
|
|
value: 'camera=(), microphone=(), geolocation=(), payment=()',
|
|
},
|
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
|
{ 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: [
|
|
{
|
|
hostname: 'avatars.githubusercontent.com',
|
|
pathname: '/**',
|
|
protocol: 'https',
|
|
},
|
|
{
|
|
hostname: 'twenty-icons.com',
|
|
pathname: '/**',
|
|
protocol: 'https',
|
|
},
|
|
],
|
|
},
|
|
linaria: {
|
|
configFile: path.resolve(__dirname, 'wyw-in-js.config.cjs'),
|
|
},
|
|
reactCompiler: true,
|
|
experimental: {
|
|
swcPlugins: [
|
|
[
|
|
'@lingui/swc-plugin',
|
|
{
|
|
runtimeModules: {
|
|
i18n: ['@lingui/core', 'i18n'],
|
|
trans: ['@lingui/react', 'Trans'],
|
|
},
|
|
},
|
|
],
|
|
],
|
|
},
|
|
async headers() {
|
|
return [
|
|
{
|
|
source: '/:path*',
|
|
headers: SECURITY_HEADERS.map((h) => ({ ...h })),
|
|
},
|
|
{
|
|
source: '/(images|illustrations|lottie)/:path*',
|
|
headers: [
|
|
{
|
|
key: 'Cache-Control',
|
|
value: 'public, max-age=31536000, immutable',
|
|
},
|
|
],
|
|
},
|
|
];
|
|
},
|
|
async rewrites() {
|
|
return {
|
|
beforeFiles: [
|
|
// Root rewrites to the source locale.
|
|
{ source: '/', destination: '/en' },
|
|
// Any path that isn't already locale-prefixed (en/, fr/), an internal
|
|
// Next.js path, a static asset folder, or a file with an extension
|
|
// rewrites to the source locale prefix. Mirrors proxy.ts Rule 4.
|
|
{
|
|
source:
|
|
'/:rest((?!en$|en/|fr$|fr/|api|_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap\\.xml|illustrations|lottie|fonts|.+\\..+).+)',
|
|
destination: '/en/:rest',
|
|
},
|
|
],
|
|
};
|
|
},
|
|
async redirects() {
|
|
return [
|
|
// Canonicalise www → apex. Host-based; fires before any locale logic.
|
|
// The root-path rule must come before the :path* one — Next.js's
|
|
// path-to-regexp leaves a literal `:path*` in the Location header
|
|
// when the parameter matches empty against an absolute destination.
|
|
{
|
|
source: '/',
|
|
has: [{ type: 'host', value: 'www.twenty.com' }],
|
|
destination: 'https://twenty.com/',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/:path*',
|
|
has: [{ type: 'host', value: 'www.twenty.com' }],
|
|
destination: 'https://twenty.com/:path*',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/',
|
|
has: [{ type: 'host', value: 'www.twenty-main.com' }],
|
|
destination: 'https://twenty-main.com/',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/:path*',
|
|
has: [{ type: 'host', value: 'www.twenty-main.com' }],
|
|
destination: 'https://twenty-main.com/:path*',
|
|
permanent: true,
|
|
},
|
|
// Strip the source-locale prefix: /en/foo → /foo (301). Mirrors proxy.ts Rule 1.
|
|
{ source: '/en', destination: '/', statusCode: 301 },
|
|
{ source: '/en/:path*', destination: '/:path*', statusCode: 301 },
|
|
// Normalise raw locale codes that aren't deployed URL segments
|
|
// (e.g. /fr-FR/foo → /foo, /de-DE/foo → /foo). Mirrors proxy.ts Rule 3.
|
|
...RAW_LOCALE_PREFIXES_TO_STRIP.flatMap((locale) => [
|
|
{ source: `/${locale}`, destination: '/', permanent: true },
|
|
{
|
|
source: `/${locale}/:path*`,
|
|
destination: '/:path*',
|
|
permanent: true,
|
|
},
|
|
]),
|
|
{
|
|
source: '/user-guide',
|
|
destination: 'https://docs.twenty.com/user-guide/introduction',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/user-guide/section/:folder/:slug*',
|
|
destination: 'https://docs.twenty.com/user-guide/:folder/:slug*',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/user-guide/:folder/:slug*',
|
|
destination: 'https://docs.twenty.com/user-guide/:folder/:slug*',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/developers',
|
|
destination: 'https://docs.twenty.com/developers/introduction',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/developers/section/:folder/:slug*',
|
|
destination: 'https://docs.twenty.com/developers/:folder/:slug*',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/developers/:folder/:slug*',
|
|
destination: 'https://docs.twenty.com/developers/:folder/:slug*',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/developers/:slug',
|
|
destination: 'https://docs.twenty.com/developers/:slug',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/twenty-ui',
|
|
destination: 'https://docs.twenty.com/twenty-ui/introduction',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/twenty-ui/section/:folder/:slug*',
|
|
destination: 'https://docs.twenty.com/twenty-ui/:folder/:slug*',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/twenty-ui/:folder/:slug*',
|
|
destination: 'https://docs.twenty.com/twenty-ui/:folder/:slug*',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/twenty-ui/:slug',
|
|
destination: 'https://docs.twenty.com/twenty-ui/:slug',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/resources/why-twenty',
|
|
destination: '/why-twenty',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/story',
|
|
destination: '/why-twenty',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/legal/privacy',
|
|
destination: '/privacy-policy',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/legal/terms',
|
|
destination: '/terms',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/legal/dpa',
|
|
destination: '/terms',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/case-studies/9-dots-story',
|
|
destination: '/customers/9dots',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/case-studies/act-immi-story',
|
|
destination: '/customers/act-education',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/case-studies/:slug*',
|
|
destination: '/customers',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/implementation-services',
|
|
destination: '/partners',
|
|
permanent: true,
|
|
},
|
|
{
|
|
source: '/onboarding-packages',
|
|
destination: '/partners',
|
|
permanent: true,
|
|
},
|
|
];
|
|
},
|
|
};
|
|
|
|
initOpenNextCloudflareForDev();
|
|
|
|
module.exports = withLinaria(nextConfig);
|