feat(website-new): add Cloudflare Workers deployment via OpenNext (#20741)

## Summary
- Adds `@opennextjs/cloudflare` adapter so `packages/twenty-website-new`
can deploy to Cloudflare Workers
- Two environments (`dev` / `prod`) wired via `wrangler.jsonc` env
blocks
- Existing Docker / EKS build path is untouched in this PR — the cutover
happens in the paired infra PR

Pairs with: https://github.com/twentyhq/twenty-infra/pull/__ (to be
opened, will swap CI + decommission Helm/ArgoCD)

## Files added
- `packages/twenty-website-new/wrangler.jsonc` — Worker config,
`nodejs_compat` flag, R2 incremental cache, Cloudflare `IMAGES` binding,
env-specific routes (`website-new.twenty-main.com` for dev; `twenty.com`
+ `www.twenty.com` for prod)
- `packages/twenty-website-new/open-next.config.ts` — minimal config
using `r2IncrementalCache`
- `packages/twenty-website-new/.dev.vars.example` — local secrets
template (`STRIPE_SECRET_KEY`, `ENTERPRISE_JWT_PRIVATE_KEY`)
- `packages/twenty-website-new/public/_headers` — immutable cache
headers for `/_next/static/*`

## Files modified
- `packages/twenty-website-new/package.json` — adds
`@opennextjs/cloudflare`, `wrangler` to devDeps; adds `preview`,
`deploy:dev`, `deploy:prod`, `cf-typegen` scripts
- `packages/twenty-website-new/next.config.ts` — calls
`initOpenNextCloudflareForDev()` (no-op outside `next dev`); preserves
Linaria CommonJS export
- `packages/twenty-website-new/.gitignore` — ignores `.open-next/`,
`.wrangler/`, `.dev.vars`, generated `cloudflare-env.d.ts`

## Compatibility notes
- `enterprise-jwt.ts` uses Node `crypto` + `Buffer` — works on Workers
with the `nodejs_compat` flag (compat date 2025-01-15, well past the
2024-09-23 minimum)
- `sharp` stays as a build-time dep (Next/Image asset processing);
runtime image optimization routes through the Cloudflare `IMAGES`
binding
- Linaria runs at build time, unaffected
- Stripe SDK is HTTP-based, fine on Workers

## One-time CF setup required before this PR is useful
The infra PR adds GitHub Actions wiring, but the Cloudflare account
itself needs:
- R2 buckets: `twenty-website-cache-dev`, `twenty-website-cache-prod`
- Worker secrets per env (via `wrangler secret put --env <dev|prod>`):
`STRIPE_SECRET_KEY`, `ENTERPRISE_JWT_PRIVATE_KEY`
- An API token with `Workers Scripts:Edit`, `Workers R2 Storage:Edit`,
`Zone DNS:Edit` on the `twenty.com` zone — stored as
`CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` in the infra repo's
GitHub secrets
- The Cloudflare Images subscription enabled on the account (binding is
configured; \$5/mo + per-transformation pricing)

## Follow-up (out of scope)
- Rename `packages/twenty-website-new` → `packages/twenty-website` and
delete the legacy `packages/twenty-website` (mechanical, separate PR to
keep this diff reviewable)
- Remove `packages/twenty-docker/twenty-website-new/` once the EKS
deploy is fully retired

## Test plan
- [ ] `yarn install` resolves new devDeps cleanly
- [ ] `cd packages/twenty-website-new && npx next build` still succeeds
(Linaria path untouched)
- [ ] `yarn preview` builds the Worker locally and serves on
http://localhost:8788
- [ ] Smoke: `/`, `/pricing`, an enterprise-key-signing flow (needs
`.dev.vars` populated)
- [ ] After CF resources are provisioned: `yarn deploy:dev` succeeds and
`website-new.twenty-main.com` serves the new Worker
This commit is contained in:
Félix Malfait
2026-05-19 23:23:54 +02:00
committed by GitHub
parent 1a9f786e42
commit 24a836cc7d
11 changed files with 2475 additions and 225 deletions
@@ -0,0 +1,7 @@
NEXTJS_ENV=development
# Stripe — use a test-mode key locally
STRIPE_SECRET_KEY=
# Enterprise JWT — generate a test RSA keypair for local dev
ENTERPRISE_JWT_PRIVATE_KEY=
+6
View File
@@ -35,6 +35,12 @@ yarn-error.log*
# vercel
.vercel
# cloudflare / opennext
.open-next/
.wrangler/
.dev.vars
cloudflare-env.d.ts
# typescript
*.tsbuildinfo
next-env.d.ts
@@ -1,5 +1,21 @@
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 = [
{
@@ -66,8 +82,44 @@ const nextConfig: LinariaConfig = {
},
];
},
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.
{
source: '/:path*',
has: [{ type: 'host', value: 'www.twenty.com' }],
destination: 'https://twenty.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',
@@ -177,4 +229,6 @@ const nextConfig: LinariaConfig = {
},
};
initOpenNextCloudflareForDev();
module.exports = withLinaria(nextConfig);
@@ -0,0 +1,6 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
});
+8 -2
View File
@@ -6,7 +6,11 @@
"dev": "npx next dev",
"build": "npx next build",
"start": "npx next start",
"convert-images": "node ./scripts/convert-png-to-webp.mjs"
"convert-images": "node ./scripts/convert-png-to-webp.mjs",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy:dev": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env dev",
"deploy:prod": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env prod",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -39,6 +43,7 @@
"@lingui/conf": "5.1.2",
"@lingui/format-po": "5.1.2",
"@lingui/swc-plugin": "^5.11.0",
"@opennextjs/cloudflare": "^1.0.0",
"@swc/core": "^1.15.11",
"@swc/jest": "^0.2.39",
"@types/jest": "^30.0.0",
@@ -49,6 +54,7 @@
"babel-plugin-react-compiler": "1.0.0",
"jest": "29.7.0",
"jest-environment-node": "^29.4.1",
"ts-jest": "^29.1.1"
"ts-jest": "^29.1.1",
"wrangler": "^4.0.0"
}
}
@@ -0,0 +1,2 @@
/_next/static/*
Cache-Control: public, max-age=31536000, immutable
@@ -1,137 +0,0 @@
import { NextRequest } from 'next/server';
import { proxy } from '@/proxy';
const SITE_ORIGIN = 'https://example.test';
type Cookies = Record<string, string>;
type Headers = Record<string, string>;
const buildRequest = (
pathname: string,
{ cookies = {}, headers = {} }: { cookies?: Cookies; headers?: Headers } = {},
): NextRequest => {
const url = new URL(pathname, SITE_ORIGIN);
const cookieHeader = Object.entries(cookies)
.map(([key, value]) => `${key}=${value}`)
.join('; ');
const allHeaders = new Headers(headers);
if (cookieHeader.length > 0) {
allHeaders.set('cookie', cookieHeader);
}
return new NextRequest(url, { headers: allHeaders });
};
const getSetCookie = (response: Response): string | null =>
response.headers.get('set-cookie');
describe('proxy: locale routing', () => {
describe('canonicalisation of explicit prefixes', () => {
it('301s /en/foo to /foo so the source locale never appears in URLs', () => {
const response = proxy(buildRequest('/en/pricing'));
expect(response.status).toBe(301);
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`);
});
it('301s the bare /en root to /', () => {
const response = proxy(buildRequest('/en'));
expect(response.status).toBe(301);
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/`);
});
it('preserves the query string on /en canonicalisation', () => {
const response = proxy(buildRequest('/en/pricing?utm_source=newsletter'));
expect(response.status).toBe(301);
expect(response.headers.get('location')).toBe(
`${SITE_ORIGIN}/pricing?utm_source=newsletter`,
);
});
it('308s an unsupported but recognised locale prefix down to the canonical path', () => {
const response = proxy(buildRequest('/de-DE/pricing'));
expect(response.status).toBe(308);
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`);
});
it('308s the legacy fr-FR URL form to bare path (the public segment is /fr now)', () => {
const response = proxy(buildRequest('/fr-FR/pricing'));
expect(response.status).toBe(308);
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`);
});
});
describe('canonical published-locale segments render directly', () => {
it('renders /fr/foo without any redirect or rewrite', () => {
const response = proxy(buildRequest('/fr/pricing'));
expect(response.status).toBe(200);
expect(response.headers.get('location')).toBeNull();
expect(response.headers.get('x-middleware-rewrite')).toBeNull();
});
it('does not write a NEXT_LOCALE cookie on locale-prefixed visits', () => {
const response = proxy(buildRequest('/fr/pricing'));
expect(getSetCookie(response)).toBeNull();
});
});
describe('bare URLs always render the source locale', () => {
it('rewrites /pricing to the internal /en/pricing for anonymous visitors', () => {
const response = proxy(buildRequest('/pricing'));
expect(response.status).toBe(200);
expect(response.headers.get('x-middleware-rewrite')).toBe(
`${SITE_ORIGIN}/en/pricing`,
);
});
it('ignores Accept-Language entirely — French browsers still see English at /pricing', () => {
const response = proxy(
buildRequest('/pricing', {
headers: { 'accept-language': 'fr-FR,fr;q=0.9' },
}),
);
expect(response.status).toBe(200);
expect(response.headers.get('x-middleware-rewrite')).toBe(
`${SITE_ORIGIN}/en/pricing`,
);
expect(response.headers.get('location')).toBeNull();
});
it('ignores stored NEXT_LOCALE cookie — bare URLs are never cookie-redirected', () => {
const response = proxy(
buildRequest('/pricing', { cookies: { NEXT_LOCALE: 'fr-FR' } }),
);
expect(response.status).toBe(200);
expect(response.headers.get('x-middleware-rewrite')).toBe(
`${SITE_ORIGIN}/en/pricing`,
);
expect(response.headers.get('location')).toBeNull();
});
it('rewrites the bare root / to the internal /en path', () => {
const response = proxy(buildRequest('/'));
expect(response.status).toBe(200);
expect(response.headers.get('x-middleware-rewrite')).toBe(
`${SITE_ORIGIN}/en`,
);
});
it('preserves query strings when rewriting bare URLs', () => {
const response = proxy(buildRequest('/pricing?utm_source=newsletter'));
expect(response.headers.get('x-middleware-rewrite')).toBe(
`${SITE_ORIGIN}/en/pricing?utm_source=newsletter`,
);
});
});
});
-45
View File
@@ -1,45 +0,0 @@
import { NextResponse, type NextRequest } from 'next/server';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { KNOWN_PUBLIC_APP_LOCALE_BY_RAW } from '@/lib/i18n/utils/app-locale-set';
import { LOCALE_BY_URL_SEGMENT } from '@/lib/i18n/utils/website-locale-segments';
export const proxy = (request: NextRequest) => {
const { pathname, search } = request.nextUrl;
const firstSlash = pathname.indexOf('/', 1);
const firstSegment =
firstSlash === -1 ? pathname.slice(1) : pathname.slice(1, firstSlash);
const tail = firstSlash === -1 ? '/' : pathname.slice(firstSlash);
const localeFromSegment = LOCALE_BY_URL_SEGMENT.get(firstSegment);
if (localeFromSegment === SOURCE_LOCALE) {
const target = request.nextUrl.clone();
target.pathname = tail;
target.search = search;
return NextResponse.redirect(target, 301);
}
if (localeFromSegment !== undefined) {
return NextResponse.next();
}
if (KNOWN_PUBLIC_APP_LOCALE_BY_RAW.has(firstSegment)) {
const target = request.nextUrl.clone();
target.pathname = tail;
target.search = search;
return NextResponse.redirect(target, 308);
}
const target = request.nextUrl.clone();
target.pathname = `/${SOURCE_LOCALE}${pathname === '/' ? '' : pathname}`;
target.search = search;
return NextResponse.rewrite(target);
};
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|illustrations|lottie|fonts|.*\\..*).*)',
],
};
@@ -31,8 +31,8 @@ function buildNavItems(): MenuNavItemType[] {
external: true,
icon: 'book',
preview: {
image: '/images/product/feature/contacts.webp',
imageAlt: 'Twenty companies list',
image: '/images/shared/menu/user-guide-preview.webp',
imageAlt: 'Twenty user guide preview',
title: msg`Master every corner of Twenty`,
description: msg`Step-by-step guides and playbooks to help your team get the most out of their workspace.`,
},
@@ -0,0 +1,50 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "twenty-website",
"main": ".open-next/worker.js",
"compatibility_date": "2025-01-15",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS",
},
"observability": {
"enabled": true,
},
"env": {
"dev": {
"name": "twenty-website-dev",
"routes": [
{
"pattern": "website-new.twenty-main.com",
"custom_domain": true,
},
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "twenty-website-cache-dev",
},
],
},
"prod": {
"name": "twenty-website-prod",
"routes": [
{
"pattern": "twenty.com",
"custom_domain": true,
},
{
"pattern": "www.twenty.com",
"custom_domain": true,
},
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "twenty-website-cache-prod",
},
],
},
},
}
+2340 -39
View File
File diff suppressed because it is too large Load Diff